https://github.com/dfa1/refined-type
exploring value types and Valhalla
https://github.com/dfa1/refined-type
Last synced: 10 days ago
JSON representation
exploring value types and Valhalla
- Host: GitHub
- URL: https://github.com/dfa1/refined-type
- Owner: dfa1
- Created: 2026-05-16T06:41:43.000Z (about 2 months ago)
- Default Branch: main
- Last Pushed: 2026-05-16T13:50:57.000Z (about 2 months ago)
- Last Synced: 2026-05-16T15:13:22.351Z (about 2 months ago)
- Language: Java
- Size: 190 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
Awesome Lists containing this project
README
# refined-type
Experiments with [Project Valhalla](https://openjdk.org/projects/valhalla/) **value classes** as a refined-type library for Java.
The core question: *can a wrapper type that enforces a constraint at construction time approach the performance of a bare primitive?*
Value classes say yes — no heap allocation, no object header, flat storage in arrays.
Inspired by: [Refined types in practice](https://kwark.github.io/refined-in-practice/#1) — Reference: [original gist](https://gist.github.com/dfa1/f6fdca0513730dc7dc7d6a5d89629709)
Background: [Rethink domain primitives with Valhalla](https://dfa1.github.io/articles/rethink-domain-primitives-with-valhalla)
---
## Why refined types?
A `double` can hold any number. When you pass `lon` where `lat` is expected the compiler stays silent and the bug ships to production.
Refined types encode the constraint in the type itself:
```java
// Before: primitive soup — caller must read the docs to know what's valid
void route(double lat, double lon) { ... }
// After: the type IS the documentation and the guard
void route(Latitude lat, Longitude lon) { ... }
```
Validation runs **once**, at construction. Every subsequent use is guaranteed-valid, with no runtime checks in hot paths. Swapping `lat` and `lon` no longer compiles. Passing `200.0` no longer compiles either.
The same pattern scales to `String` when the constraint is structural. An ISIN (ISO 6166 securities identifier) is a 12-character code: two-letter country prefix, nine-character NSIN, and a Luhn check digit. A raw `String` enforces none of that:
```java
// Before: any string is accepted — validation is the caller's problem
void settle(String isin, String currency) { ... }
settle("US037833100X", "USD"); // bad check digit — compiles, fails at runtime or never
settle("USD", "US0378331005"); // args swapped — compiles, silent wrong-currency settlement
```
```java
// After: constraints live in the types
void settle(Isin isin, CurrencyCode currency) { ... }
// Build from components — check digit computed, cannot be wrong
Isin apple = new Isin(new CountryCode("US"), "037833100"); // → US0378331005
// Domain logic belongs to the type, not scattered across callers
CountryCode country = apple.country(); // → CountryCode("US")
// Swapping args does not compile; CurrencyCode and Isin are distinct types
settle(apple, new CurrencyCode("USD"));
```
---
## Requirements
| Tool | Version |
|-------|------------------------------------------|
| Java | 27 EA (Valhalla preview) — **required** |
| Maven | 3.9+ |
Download a [JEP 401 EA build](https://jdk.java.net/valhalla/) and set `JAVA_HOME` to its home directory before running any Maven command.
```bash
java -version # must show openjdk 27-jep401ea3
```
---
## Build
```bash
JAVA_HOME= ./mvnw compile # compile
JAVA_HOME= ./mvnw test # ~500 tests
```
Use `bench.sh` to run JMH benchmarks — it sets `JAVA_HOME`, compiles, and launches JMH:
```bash
./bench.sh # all benchmarks
./bench.sh SwissValorNumber # filter by name (regex)
./bench.sh --gc SwissValorNumber # add GC alloc-rate profiler
./bench.sh --layout SwissValorNumber # JOL memory layout, then benchmarks
```
---
## What's in the box
### Marker interfaces
| Interface | Type | Default `compareTo` |
|-------------------|-----------|-------------------------------|
| `RefinedInt` | `int` | `Integer.compare` |
| `RefinedShort` | `short` | `Short.compare` |
| `RefinedLong` | `long` | `Long.compare` |
| `RefinedFloat` | `float` | `Float.compare` |
| `RefinedDouble`| `double` | `Double.compare` |
| `RefinedString`| `String` | `String.compareTo` |
Each implementation pins the self-type:
```java
public value class Probability implements RefinedFloat { ... }
```
Why? To make something like `Probability.compareTo(Price)` a compile-time error. This is called
[F-bounded](https://en.wikipedia.org/wiki/Bounded_quantification#F-bounded_quantification).
### Unsigned integers (`unsigned` package)
Java's primitives are signed. These value classes give proper unsigned semantics.
#### Why unsigned integers matter
**Domain correctness.** Quantities like a TCP port, a pixel component, a memory address offset, a file size, or a CRC checksum are inherently non-negative. Representing them as `int` or `long` lets `-1` or `-80` compile without complaint. A value class with a [0, 2³²−1] constructor rejects the invalid state at construction time.
**C / C++ / Rust interop.** The Foreign Function & Memory (FFM) API (Java 22+, `java.lang.foreign`) lets Java call native code directly. C uses `uint8_t`, `uint16_t`, `uint32_t`, and `uint64_t` everywhere — network structs, OS syscalls, hardware registers. There is no automatic sign-extension barrier at the JNI / FFM boundary: a C `uint32_t` with value `4_000_000_000` arrives in Java as `-294_967_296` if you naively store it in `int`. Wrapping the raw bits in `UnsignedInt` makes the intent and the correct read-back (`asLong()`) explicit.
```java
// FFM snippet — reading a C uint32_t field from a MemorySegment
int rawBits = segment.get(ValueLayout.JAVA_INT, offset); // signed bits
UnsignedInt count = UnsignedInt.ofBits(rawBits); // wrap, no sign error
System.out.println(count.asLong()); // correct unsigned magnitude
```
**Network protocols and binary formats.** IPv4 addresses, UDP/TCP port numbers, DNS record TTLs, PNG chunk lengths, Ethernet frame types — all unsigned. Parsing binary protocols without an unsigned type forces the developer to remember `& 0xFFFFFFFFL` at every read site. An `UnsignedInt` centralises that contract.
**The JDK gap.** The JDK provides `Integer.toUnsignedLong`, `Integer.compareUnsigned`, and `Integer.divideUnsigned` — unsigned *operations* on signed carriers — but no unsigned *types*. These helpers are easy to forget or misapply. The types in this package wrap the exact same bit-patterns and delegate to those helpers, so the correctness burden shifts from the call site to the constructor.
> **Future:** Valhalla value classes + potential JDK unsigned primitives (discussed in the amber-dev mailing list) could make these wrappers zero-overhead. Until then, they carry a 16–24 byte object header on the heap — acceptable for correctness-critical code, suboptimal for hot loops (use arrays of primitives there).
| Class | Range | Notes |
|-----------------|------------------|-----------------------------------------------------------|
| `UnsignedByte` | [0, 2⁸ − 1] | stored as `byte`; constructor takes `int` |
| `UnsignedShort` | [0, 2¹⁶ − 1] | stored as `short`; constructor takes `int` |
| `UnsignedInt` | [0, 2³² − 1] | stored as `int`; `value()` returns raw bits, `asLong()` returns magnitude |
| `UnsignedLong` | [0, 2⁶⁴ − 1] | stored as `long`; every bit-pattern is valid |
Arithmetic suite named after `BigInteger` / `BigDecimal`: `add`, `subtract`, `multiply`, `divide`, `remainder`. Overflow wraps mod 2^N; division uses `Integer.divideUnsigned` / `Long.divideUnsigned`.
```java
var a = new UnsignedInt(3_000_000_000L);
var b = new UnsignedInt(1_000_000_000L);
UnsignedInt sum = a.add(b); // 4_000_000_000 — no overflow
UnsignedLong max = UnsignedLong.MAX; // 2^64-1, stored as -1L
UnsignedLong wrapped = max.add(new UnsignedLong(1L)); // wraps to ZERO — mod 2^64
```
**Cross-type arithmetic** uses explicit widening — same contract as `Short.toUnsignedInt` in the JDK:
```java
UnsignedShort s = new UnsignedShort(1_000);
UnsignedInt i = new UnsignedInt(3_000_000_000L);
UnsignedInt result = s.toUnsignedInt().add(i); // widen first, then add
```
### Half-precision float (`fp` package)
`Float16` — IEEE 754 binary16, stored in **2 bytes**. Range ±65 504, ~3.31 decimal digits.
```java
Float16 a = Float16.of(1.5f);
Float16 sum = a.add(Float16.of(0.5f)); // Float16(2.0)
Float16.MAX_VALUE.value(); // 65504.0f
Float16.NaN.isNaN(); // true
```
Arithmetic promotes through `float32` internally — the value-class benefit is **storage density**, not per-operation compute speed on the JVM. Ideal for ML weight arrays, sensor streams, HDR textures.
### Examples (`examples` package)
| Class | Constraint |
|---------------------|-----------------------------------------------------------|
| `Age` | integer in `[0, 150]` |
| `AudioSample` | signed 16-bit PCM sample |
| `Bic` | ISO 9362 bank identifier (8- or 11-char input, canonical 11-char storage, `bankCode`/`country`/`locationCode`/`branchCode`) |
| `CountryCode` | ISO 3166-1 alpha-2 (two uppercase letters) |
| `CurrencyCode` | ISO 4217 currency code (three uppercase letters) |
| `CusipNumber` | 9-char CUSIP (US/Canadian securities), → `Isin` |
| `Distance` | non-negative metres (with `Unit` enum: M/KM/MILE/NM) |
| `Email` | coarse syntactic check |
| `Coordinate` | (Latitude, Longitude) pair with haversine `Distance` |
| `HostName` | RFC 1123 hostname with SSRF guards |
| `Iban` | ISO 13616 bank account number (MOD 97-10 checksum, `country`/`checkDigits`/`bban`; `of()` computes check digits) |
| `Isin` | ISO 6166 securities identifier (12 chars) |
| `Latitude` | decimal degrees in `[-90, 90]` |
| `MacAddress` | IEEE 802 MAC address (colon/hyphen/plain input, canonical `aa:bb:cc:dd:ee:ff`; `isMulticast`, `isLocallyAdministered`, `isBroadcast`) |
| `Money` | `Price` + `CurrencyCode`; `add`/`subtract`/`multiply`/`negate` enforce currency matching |
| `Lei` | ISO 17442 Legal Entity Identifier (20 chars, MOD 97-10 check digits) |
| `Longitude` | decimal degrees in `[-180, 180]` |
| `Percentage` | finite float in `[0, 100]` |
| `Port` | TCP/UDP port in `[0, 65 535]` |
| `PositiveInt` | integer strictly greater than zero — minimal example |
| `Price` | strictly positive, finite float |
| `Probability` | finite float in `[0, 1]` |
| `Size` | non-negative byte count (with `Unit` enum) |
| `Slug` | URL-safe lowercase identifier |
| `SwissValorNumber` | SIX Valoren-Nummer, `[1, 999 999 999]`, → `Isin` |
| `Temperature` | non-negative Kelvin; construct/convert via `Unit` (K/°C/°F) |
| `Speed` | non-negative float (m/s) |
| `Volume` | non-negative, finite float |
#### A worked example — securities identifiers
National identifiers compose into international ISINs with check digits computed by the type:
```java
// ABB Ltd — Swiss listing
Isin abb = new SwissValorNumber(1_222_171).toIsin(); // CH0012221716
// Apple Inc. — US listing
Isin apple = new CusipNumber("037833100").toIsin(); // US0378331005
// Or construct directly from country + NSIN; check digit is computed
Isin tesla = new Isin(new CountryCode("US"), "88160R101"); // US88160R1014
```
The wrong-type bug `swissValor.toIsin().equals(cusip)` doesn't compile; both ISINs do compare to each other, but a `SwissValorNumber` cannot be passed where a `CusipNumber` is expected.
---
## Value-class semantics
### Equality and hash codes
JEP 401 value objects have no identity — two instances with identical field values **are** the same object at the JVM level. `==`, `equals`, and `hashCode` all work correctly without any override:
```java
Age.of(30) == Age.of(30) // true
Age.of(30).equals(Age.of(30)) // true
Age.of(30).hashCode() == Age.of(30).hashCode() // true
Email.of("a@b.com").equals(Email.of("a@b.com")) // true — String field compared by value
Coordinate.of(Latitude.of(1.0), Longitude.of(2.0))
.equals(Coordinate.of(Latitude.of(1.0), Longitude.of(2.0))) // true — multi-field
```
No class in this repo overrides `equals` or `hashCode`. Only `toString` is overridden for human-readable output like `Age(30)`.
### Pattern matching
Value classes participate in Java's pattern-matching features:
```java
Object obj = Age.of(42);
// instanceof pattern
if (obj instanceof Age a) {
System.out.println(a.value()); // 42
}
// switch expression
String label = switch (obj) {
case Age a -> "age: " + a.value();
default -> "other";
};
```
Works for both primitive-backed types (`Age` wraps `short`) and `String`-backed types (`Email`).
---
## Framework integration
Refined types are opaque to frameworks that expect primitives or `String`. Two opt-in adapters ship in this repo.
### Jackson (`jackson` package)
Register `RefinedTypeModule` once:
```java
ObjectMapper mapper = new ObjectMapper()
.registerModule(new RefinedTypeModule());
```
**Serializers** are shared via marker interface — one serializer covers all `RefinedInt` (or `RefinedString`, `RefinedShort`) implementations:
```java
// Age(42) → 42, Port(8080) → 8080, Email("a@b.com") → "a@b.com"
mapper.writeValueAsString(new Age(42)); // "42"
mapper.writeValueAsString(new Email("a@b.com")); // "\"a@b.com\""
```
**Deserializers** are per concrete type — the constructor is the only valid factory and each type has its own constraint:
```java
mapper.readValue("42", Age.class); // Age(42)
mapper.readValue("\"a@b.com\"", Email.class); // Email("a@b.com")
mapper.readValue("151", Age.class); // throws InvalidDefinitionException (> 150)
```
Currently registered: `Age`, `Port`, `Email`, `CountryCode`, `CurrencyCode`.
To add a new type, implement `StdDeserializer` and register it in `RefinedTypeModule`:
```java
class PortDeserializer extends StdDeserializer {
PortDeserializer() { super(Port.class); }
@Override
public Port deserialize(JsonParser p, DeserializationContext ctx) throws IOException {
return new Port(p.getIntValue());
}
}
```
### JPA (`jpa` package)
Abstract base converters handle the `null` column ↔ `null` attribute contract.
Concrete converters are one-liners:
```java
@Converter
public class AgeConverter extends AbstractRefinedShortConverter {
@Override
protected Age fromShort(short value) { return new Age(value); }
}
```
Apply with `@Convert` on the entity field:
```java
@Entity
public class Person {
@Convert(converter = AgeConverter.class)
private Age age;
@Convert(converter = EmailConverter.class)
private Email email;
}
```
`NULL` in the column maps to `null` in the entity field; non-null values are reconstructed through the constructor so the constraint is re-validated on load.
Currently provided: `AgeConverter`, `PortConverter`, `EmailConverter`, `CountryCodeConverter`, `CurrencyCodeConverter`.
Abstract bases available for new types: `AbstractRefinedIntConverter`, `AbstractRefinedShortConverter`, `AbstractRefinedStringConverter`.
---
## Benchmark results
Measured on JDK 27-jep401ea3 (Valhalla EA3), macOS Darwin 25.4.0, Apple Silicon.
Run with `./bench.sh --gc `. Each method uses `@Fork(1)` — separate JVM per variant
to prevent JIT cross-contamination.
### Construction — `SwissValorNumberConstructionBenchmark`
Builds 1 000 elements from pre-seeded raw ints. Allocation is the dominant cost.
| Variant | Time (µs/op) | Alloc (B/op) | What allocates |
|----------------------|-------------:|-------------:|---------------------------------------------------------------------------------------------|
| `bareInt` | 0.078 | 4 016 | one `int[1000]` (4 KB, `System.arraycopy`) |
| `valueClass` | 0.600 | 8 016 | one `SwissValorNumber[1000]` flat array (EA3 boxing overhead; JOL confirms 4 B/element at rest) |
| `listOfValueClass` | 2.814 | 28 040 | `ArrayList` header + `Object[1000]` refs + 1 000 × 24-byte boxed heap objects |
| `identityClass` | 2.350 | 20 016 | one reference array + 1 000 × 16-byte heap objects |
**`listOfValueClass` vs `valueClass`** proves the generics-boxing penalty: `List`
erases the flat-layout benefit, allocating 3.5× more bytes than the typed array (28 040 vs 8 016 B/op).
Use typed arrays (`SwissValorNumber[]`) in hot paths; reserve `List` for convenience APIs.
**Identity class allocates 5× more bytes than bare int.** Value class allocates 2× on EA3 because
the constructor still boxes temporarily before writing into the flat array. In production Valhalla
(JEP 401 fully optimised) value class should converge to bare int (~4 016 B/op).
### Scan — `SwissValorNumberBenchmark`
Linear scan for maximum over 100 000 pre-allocated elements. All variants are allocation-free
(arrays built in `@Setup`); the difference is pure cache pressure.
| Variant | Time (µs/op) | Alloc (B/op) |
|-----------------|-------------:|-------------:|
| `bareInt` | 11.927 | ≈ 0 |
| `valueClass` | 29.170 | ≈ 0 |
| `identityClass` | 36.358 | ≈ 0 |
**Zero allocation in steady state — all three variants.** Value class is ~2.4× slower than bare int on
EA3 (flat array traversal not yet fully JIT-optimised); identity class is ~3× slower from scattered
heap objects stressing the prefetcher. In production Valhalla the value-class gap should close.
### Haversine scan — `CoordinateBenchmark`
Sums haversine distances from 100 000 random world coordinates to Rome. `Coordinate` nests two
`double`-backed value classes (`Latitude`, `Longitude`); the identity mirror stores two raw `double`
fields. No bare-primitive baseline — the two-field identity class is already the simplest form.
| Variant | Time (ms/op) | Alloc (B/op) |
|-----------------|-------------:|-------------:|
| `valueClass` | 2.956 | ≈ 0 |
| `identityClass` | 3.106 | ≈ 0 |
Both are allocation-free. Value class is **~5% faster** — flat array layout improves spatial locality
vs. the identity class's scattered 32-byte heap objects (JOL: 100 000 × 32 B = 3.6 MB vs. 400 KB flat).
---
## Design principles
- **Constructor validates; the type proves it.** Throw `IllegalArgumentException` with a message naming the violated constraint. Never a silent truncation.
- **No nulls.** Constructors reject null inputs; a refined type always holds a valid, non-null value.
- **Fail fast, succeed forever.** Validation runs once at the boundary. Hot paths carry guaranteed-valid values.
- **Explicit over implicit.** Cross-type widening is manual (`toUnsignedInt()`). Jackson integration is opt-in. Caching is opt-in.
- **BigInteger naming for arithmetic.** `add`, `subtract`, `multiply`, `divide`, `remainder` — same names as `BigInteger`/`BigDecimal`.
- **F-bounded markers.** `RefinedFloat>` blocks `probability.compareTo(price)` at compile time — same pattern as `java.lang.Enum>`.
- **Length before regex.** String constructors check length (or reject obviously-wrong sizes) *before* calling the regex. An unbounded input can trigger catastrophic backtracking (ReDoS) even on simple patterns; a cheap length guard caps the input and makes regex evaluation linear in the bound.
---
## Trade-offs
This pattern is a net positive in the right place, with real costs. Don't apply it project-wide.
### Where it pays
- **Constraint lives in the type signature.** `void route(Latitude, Longitude)` cannot be called with swapped args, cannot accept NaN, cannot accept 200°. The compiler enforces what comments used to beg for.
- **Validation runs once, at the boundary.** Hot paths carry a proof, not raw bytes that "should be valid."
- **Conversions belong to the type** (`SwissValorNumber.toIsin`, `AudioSample.toAmplitude`). Behavior moves toward data; the call site reads as domain prose.
- **Value classes (when Valhalla lands)** push the perf overhead to zero — the wrapper and the bare primitive flatten the same.
- **F-bound stops cross-type compares** like `probability.compareTo(price)` at compile time.
### Costs to take seriously
- **Boilerplate per type.** Constructor, `value()`, `toString`, test class. Eighteen refined types ≈ eighteen near-identical skeletons. Languages with first-class refinement (Scala 3 opaque types, Rust newtype, Kotlin value class) say this in one line.
- **Boundary friction.** Jackson, JPA, JDBC, Bean Validation, MapStruct all expect primitives and `String`. Each refined type needs an adapter — or you pay deserialization tax at every external edge.
- **Generic noise leaks.** `RefinedFloat>` is the right shape but ugly in error messages — new contributors stare at it.
- **Sweet spot is narrow.** Big API surfaces, regulated domains (finance, geo, medical), public libraries — huge win. Internal scripts, throwaway endpoints, glue code — boilerplate eats the win.
### Where it pays best
Financial, regulatory, safety-critical code where wrong-type bugs are expensive and inputs cross many layers. The CUSIP/Valor/ISIN chain in this repo is the canonical example: `toIsin()` is impossible to misuse, and passing an Apple CUSIP where a German WKN is expected does not compile.
### Prior art worth checking
| Language | Mechanism |
|----------|--------------------------------------------------------|
| Scala | `refined`, `iron`, opaque types |
| Kotlin | value classes (`@JvmInline`) |
| Rust | newtype pattern (`pub struct Latitude(f64)`) |
| Haskell | `newtype` + smart constructors |
| F# | units of measure (catches lat/long swap at compile time *without* the wrapper noise) |