measured (Kotlin)
A small Kotlin Multiplatform library that encodes a quantity's dimension directly as the generic parameter of Measure<T: Units>, builds compound dimensions out of nested generic types (UnitsProduct, UnitsRatio, InverseUnits) rather than an exponent vector, and combines them through a hand-written table of operator fun overloads — type-safe at compile time, with dimensions fully erased at runtime.
| Field | Value |
|---|---|
| Language | Kotlin (declared toolchain 2.3.20); Kotlin Multiplatform — JVM (target 1.8), JS, Wasm-JS, iOS, macOS, watchOS, tvOS, Android-Native, mingwX64, linux{X64,Arm64} |
| License | MIT |
| Repository | nacular/measured |
| Documentation | Dokka API site · README.md · Module.md |
| Key authors | Nicholas Eddy (nacular; formerly pusolito) |
| Category | Library-level compile-time checking (no compiler support; ordinary generics + operator overloading) |
| Mechanism | Dimension is the generic parameter T of Measure<T: Units>; compound dimensions are nested generic types (UnitsProduct<A, B>, UnitsRatio<A, B>, InverseUnits<T>) combined by a hand-written overload table |
| Exponent domain | None explicit — powers are structural product types (Square<T> = UnitsProduct<T, T>); Kotlin has no type-level integers, so no fractional and no general exponent algebra |
| Checking time | Compile time (Kotlin type-checker + overload resolution); JVM type erasure leaves no runtime dimension representation |
| Analyzed version | 3f65500 (pinned clone, 2026-04-05; tag v0.5.0 points at this commit) |
| Latest release | v0.5.0 (2026-04-05) |
NOTE
measured is this survey's data point for the nested-generics-as-dimensions, no-exponent-algebra mechanism: unlike the exponent-vector systems (uom's typenum ℤ⁷, Boost.Units's MPL type-lists) that compute a normal form, measured never normalizes — A·B and B·A are distinct types, and every legal combination is a separately-written operator fun overload. It is the JVM cousin of squants (also value-in-the-type on the JVM, but class-based rather than generic) and a lighter contrast to Scala's type-level coulomb. Read it against the type-system mechanism taxonomy and the free-abelian-group model it deliberately does not implement. See the comparison capstone for the cross-system synthesis.
Overview
What it solves
measured gives Kotlin code compile-time dimensional analysis with an "intuitive, mathematical" surface: a value carries its unit in its static type, so passing metres where seconds are expected is a type error, and multiplying/dividing quantities yields new compound-unit types. The README states the positioning in its first paragraph (README.md L8):
"Measured provides a safe and simple way to work with units of measure. It uses the compiler to ensure correctness, and provides intuitive, mathematical operations to work with any units. This means you can write more robust code that avoids implicit units. Time handling for example, is often done with implicit assumptions about milliseconds vs microseconds or seconds. Measured helps you avoid pitfalls like these."
The shipped model is deliberately small — six dimension files (Length, Mass, Time, Angle, BinarySize, GraphicsLength), with the compound Velocity/Acceleration aliases living in the core Units.kt, sitting on one 429-line core (Units.kt) — and its ambition is ergonomics on the JVM/Multiplatform stack (UI/graphics/time code) rather than a complete SI. The time example is the library's motivating case: Measure<Time> makes "is this milliseconds or seconds?" a type-checked question, with the base unit fixed at milliseconds (Time.kt L10).
Design philosophy
Two decisions define measured against the other systems in this survey.
The dimension is the generic parameter, not an exponent vector. A quantity is Measure<T: Units> where T is the unit's own type; a compound dimension is a nested generic type built from three combinators — UnitsProduct<A, B>, UnitsRatio<A, B>, and InverseUnits<T> (Units.kt L55, L66, L77). Velocity is literally the type alias UnitsRatio<Length, Time> (Units.kt L427). There is no exponent list and no reduction step: the "algebra" of dimensions is emulated by a large table of operator fun overloads that spell out each legal shape by hand (see How it works).
A unit carries only a scale factor to its base. The whole of the Units base class is a suffix string and a multiplicative ratio to the base unit (Units.kt L17):
// measured: Units.kt L17 — the entire unit model is a suffix + a scale ratio
abstract class Units(val suffix: String, val ratio: Double = 1.0) {
// ...display helpers, equals/hashCode over (suffix, ratio)...
}Everything downstream — conversion (in/as), comparison, the compound-unit ratio products — is arithmetic on that single Double. This is what makes the library tiny and extensible, and it is also the source of its sharpest limitation: a purely multiplicative ratio cannot express an affine unit like Celsius or Fahrenheit (see Expressiveness edges), which the README states outright (README.md L144–146):
"Measured currently only supports linear units where all members of a given unit are related by a single magnitude. This applies to many units, but Fahrenheit and Celsius are examples of temperature units that requires more than a multiplier for conversion."
How it works
Units — a unit is a labelled scale factor
Every concrete dimension is an open class extending Units, declaring its members as companion object constants whose ratio places them relative to the base unit. The base unit takes the default ratio = 1.0; the rest are multiples of it (Length.kt L6–18):
// measured: Length.kt L6-18 (abridged) — members are scale factors off the base (m)
open class Length(suffix: String, ratio: Double = 1.0): Units(suffix, ratio) {
operator fun div(other: Length) = ratio / other.ratio // Length / Length -> Double
companion object {
val meters = Length("m" ) // base: ratio 1.0
val centimeters = Length("cm", 0.0100)
val kilometers = Length("km", 1000.0000)
val miles = Length("mi", 1609.3440)
// ...
}
}Conversion is a single division of ratios. Measure<T>.in returns the magnitude in a requested member, and as returns a re-based Measure (Units.kt L103–108):
// measured: Units.kt L103-108
infix fun <A: T> `as`(other: A): Measure<T> = if (units == other) this else Measure(this `in` other, other)
infix fun <A: T> `in`(other: A): Double = if (units == other) amount else amount * (units.ratio / other.ratio)Measure<T> — the value carrier
Measure<T: Units> is the one value type: an amount: Double plus its units: T, implementing Comparable<Measure<T>> (Units.kt L98). Same-dimension arithmetic lives on it as members, so both operands must have the same T (Units.kt L113–131):
// measured: Units.kt L113-131 (abridged) — same-dimension add/sub/scale; note the shared T
class Measure<T: Units>(val amount: Double, val units: T): Comparable<Measure<T>> {
operator fun plus (other: Measure<T>): Measure<T> = minOf(units, other.units).let { Measure((this `in` it) + (other `in` it), it) }
operator fun minus(other: Measure<T>): Measure<T> = minOf(units, other.units).let { Measure((this `in` it) - (other `in` it), it) }
operator fun times(other: Number ): Measure<T> = amount * other.toDouble() * units // scalar
operator fun div (other: Number ): Measure<T> = amount / other.toDouble() * units // scalar
// ...
}plus/minus convert both operands to the smaller unit (via minOf on the ratios, Units.kt L84–89) and add — so 500 m + 1.5 km is well-typed and yields 2000 m (verified locally, Diagnostics). Because both parameters are Measure<T> with the same T, adding a Measure<Length> to a Measure<Time> has no matching overload and is a compile error — the core of the m + s experiment.
The overload table — dimensional "algebra" spelled out by hand
Cross-dimension multiplication and division are free functions, not members, and there is one overload per structural shape of the operands. The base case builds a product type; a matching inverse cancels to a raw Double (Units.kt L166–176, L228):
// measured: Units.kt L166-176, L228 (abridged) — a slice of the Units*Units / Measure*Measure table
operator fun <A: Units, B: Units> A.times(other: B ): UnitsProduct<A, B> = UnitsProduct(this, other) // A * B
@JvmName("times7") operator fun <A: Units> A.times(other: InverseUnits<A>): Double = ratio * other.ratio // A * (1/A) -> scalar
operator fun <A: Units, B: Units> A.times(other: UnitsRatio<B, A>): Measure<B> = ratio / other.denominator.ratio * other.numerator
// and on Measures:
@JvmName("times1") operator fun <A: Units, B: Units> Measure<A>.times(other: Measure<B>): Measure<UnitsProduct<A, B>> = amount * other.amount * (units * other.units)@JvmName disambiguation is pervasive because JVM erasure collapses these signatures to the same descriptor. Units.kt carries 127 operator fun lines — 110 active declarations plus 17 commented-out (16 // FIXME holes and one disabled div) — across four regions (Units * Units, Units / Units, Measure * Measure, Measure / Measure, plus Measure * Units and Number - Measure). Crucially, the table is incomplete by construction: 16 combinations are checked in as commented-out // FIXME lines — every one of them a UnitsProduct operand that Kotlin's overload resolution cannot disambiguate from its siblings (Units.kt L179–186, L281–288):
// measured: Units.kt L179-186 — the acknowledged holes in the algebra (commented out)
// FIXME operator fun <A: Units, B: Units> UnitsProduct<A, B>.times(other: InverseUnits<A>): Measure<B> = units * other
// FIXME operator fun <A: Units, B: Units> UnitsProduct<A, B>.times(other: InverseUnits<B>): A = units * other
// ...six more...This is the mechanism's signature trade-off, examined under Extensibility and Weaknesses: with no exponent normal form, cancellation and re-association must each be enumerated, and some enumerations collide.
Dimension representation
The dimension of a quantity is the generic type argument T of Measure<T: Units>, carried structurally: a base dimension is a leaf Units subclass (Length, Time, …), and a compound dimension is a nested tree of three combinators (Units.kt L55–77):
UnitsProduct<A, B>— the productA·B, withratio = A.ratio * B.ratio(Units.ktL55).UnitsRatio<A, B>— the quotientA/B, with a lazily-builtreciprocal(Units.ktL66–68).InverseUnits<T>—1/T, withratio = 1 / T.ratio(Units.ktL77).
Powers are structural, not numeric: the only "power" is typealias Square<T> = UnitsProduct<T, T> (Units.kt L57), i.e. a product type. There is no type-level integer anywhere; Acceleration is spelled UnitsRatio<Length, Square<Time>> (Units.kt L428), a nested tree, not an exponent T⁻². Three consequences follow directly, and distinguish measured from every exponent-vector system in this survey:
- No normal form. The type checker never reduces a dimension.
UnitsProduct<A, B>andUnitsProduct<B, A>are different types;A·A⁻¹does not automatically reduce to dimensionless — it reduces only because a specific overload (A.times(InverseUnits<A>): Double,Units.ktL167) is written to return a rawDouble. Order-sensitivity is documented as a first-class limitation (README.mdL112–122):radians * secondshas typeUnitsProduct<Angle, Time>andseconds * radianshas typeUnitsProduct<Time, Angle>, and the two are not interchangeable (reproduced under Diagnostics). - Dimensionless collapses to
Double. Because cancellation is modeled by overloads that returnDouble(or a scale factor), a fully-cancelled quantity is a bareDouble, not a distinguished dimensionlessMeasure. There is no dimension-one type in the free-abelian-group sense;measuredapproximates the group with a finite, hand-written multiplication table rather than realizing it. - The runtime carries nothing. On the JVM,
Measure<Length>andMeasure<Time>are the same erased classMeasure; the dimension exists only in the static type tree. Safety is entirely a compile-time property (see Zero-cost story).
Checking & inference
Checking is ordinary Kotlin type-checking plus operator-overload resolution — no compiler plugin, no annotation processor, no code generation (the source even muses "// TODO: Kapt code generation possible?", Units.kt L244). Two rules do all the work:
- Addition/subtraction demand identical
T.plus/minusare members ofMeasure<T>takingMeasure<T>(Units.ktL113–118); a mismatched dimension has no applicable overload and fails resolution. This is them + srejection. - Multiplication/division select a compound result type by structural pattern. The free-function table (
Units.ktL166–320) picks the overload whose parameter shapes match the operands and produces the corresponding nested-generic result. Where the table has an entry, the result type is inferred with no annotation; forward inference is pleasant — the README'sval velocity = 5 * meters / secondsinfersMeasure<Velocity>without a written type (README.mdL36).
What the checker cannot do:
- Invert arithmetic or generalize over dimension. There is no Kennedy-style principal-type inference (Kennedy's type system); a function generic over "any dimension raised to a power" cannot be written, because there is no type-level exponent to be generic over. User code can be generic over a fixed shape (
fun <T: Units> f(m: Measure<T>)), asabs/round/ceil/floorare (Units.ktL346–361), but not over the exponent algebra. - Resolve every compound combination. The
// FIXMEgaps (Units.ktL179–186, L281–288) mean some well-defined products ofUnitsProductoperands simply have no overload and won't type-check — the checker is as complete as the hand-written table, no more.
Extensibility
Extensibility is where measured's value-is-a-scale-factor model shines, and where its no-algebra model shows its seams.
New members on an existing dimension — trivial. Because a unit is just a
suffix+ratio, a newLengthis one constructor call from user code, and it interoperates immediately (README.mdL72–84):kotlin// measured: README.md L73-80 — a user-defined Length member, no library change val hands = Length("hands", 0.1016) // define new unit inline val v: Measure<Velocity> = 100_000 * hands / hours println(5 * hands `as` meters) // 0.508 mNew dimensions — a small class. A brand-new dimension is an
open classextendingUnitswith acompanion objectof members and adiv(other: Self): Doublefor same-dimension division; the README'sBlitsexample builds one and composes it intoUnitsRatio<Blits, Time>compound types (README.mdL88–110). No registration step exists — there is no central dimension registry, because there is no exponent vector whose length would need fixing.New compound interactions — potentially manual. This is the cost of the non-normalizing design. Any product/quotient shape the built-in table doesn't cover needs a user-supplied
operator fun. The README documents the canonical case — operand order — and shows the fix as a hand-written extension (README.mdL124–142):kotlin// measured: README.md L128-134 — user extension to canonicalize Mass-before-Length ordering operator fun Length.times(mass: Mass) = mass * this val f1 = 1 * (kilograms * meters) / (seconds * seconds) val f2 = 1 * (meters * kilograms) / (seconds * seconds) // f1 and f2 now share a typeThe library ships exactly this workaround for its own dimensions —
Time · Lengthis redirected toLength · Time(Length.ktL23–24) andLength · MasstoMass · Length(Mass.ktL15–16) — proof that canonicalization is a per-pair chore, not a systemic guarantee.
There is no cross-"system" story because there are no systems: every dimension is a peer Units subclass in one flat namespace, and any two can be multiplied the moment an overload (built-in or user-written) connects them.
Expressiveness edges
- Fractional powers: impossible. Powers are structural product types (
Square<T> = UnitsProduct<T, T>,Units.ktL57) and Kotlin has no type-level integers, so there is nosqrton dimensions and no way to spellLength^(1/2). Not merely unimplemented — unrepresentable in the mechanism, a stronger absence thanuom's integer-only limitation. - Affine / temperature: absent by design.
Unitscarries only a multiplicativeratio(Units.ktL17); there is noTemperaturedimension at all in the library (a search ofsrc/finds no temperature file), precisely because °C/°F need an additive offset the model can't hold. The README says so directly (README.mdL144–146, quoted in Design philosophy). For how a proper point-vs-difference treatment would look, see the torsor / affine-quantity model;measuredimplements none of it. - Logarithmic quantities: absent. No decibel, neper, or level type; the model is purely multiplicative scale factors. (
BinarySizecovers bits/bytes and their decimal and binary multiples — kB vs KiB — but these are ordinary ratios, not a log scale;BinarySize.kt.) - Angle: a first-class dimension, not dimensionless-with-tag. Unlike the kind-tagged approaches,
Angleis a genuineUnitssubclass withradians(base) anddegrees(ratio = π/180, printed without a space) and its own trig (Angle.ktL11–19):sin/cos/tantake aMeasure<Angle>and convert to radians internally (Angle.ktL21–33). The upside is that a bareDoublecannot be passed where an angle is expected; the downside is that angle is not unified with the dimensionlessDoublethat cancellation produces, soAngularVelocity · Timedoes not automatically land back onAngle— it depends on which overloads exist. - Kind-vs-dimension disambiguation: not modeled. There is no
Kindtag (contrastuom); same-dimension-different-meaning quantities (torque vs energy, frequency vs becquerel) are not distinguished, because the library's scope is a handful of physical/graphics dimensions rather than a complete ISQ. The one meaning-level distinction it does draw —LengthvsGraphicsLength(physical distance vs on-screen pixels,GraphicsLength.kt) — is achieved simply by making them separateUnitssubclasses, so pixels and metres never accidentally add.
Zero-cost story
measured's cost story is runtime-representational, not zero-cost-in-the-Rust-sense, and it is honest to say so:
- Dimensions are erased, but the wrapper is not free.
Measure<T>is a real heap class (class Measure<T: Units>(val amount: Double, val units: T),Units.ktL98) holding a boxedDoubleamount and a reference to aUnitsobject. It is not@JvmInline value class-optimized; there is no#[repr(transparent)]analogue. EveryMeasureis an allocation, and every arithmetic op allocates a new one (e.g.plusreturns a freshMeasure,Units.ktL113). So the safety is compile-time-only, but the runtime pays object and boxing overhead that a rawDoublewould not — the opposite pole fromuom's verifiedsize_of::<Length>() == size_of::<f64>(). - The unit's
ratiois consulted at runtime. Conversion (in/as) and same-unit-normalizingplus/minusdoDoubledivisions onunits.ratioat runtime (Units.ktL108, L113); compoundratiois recomputed by multiplying the operands' ratios (Units.ktL55). None of this is folded away — the unit objects are live values, not phantom types. - Type erasure has a soundness cost.
Measure.equalscasts an untypedMeasure<*>toMeasure<T>with an unchecked cast (Units.ktL149) — the compiler emitswarning: unchecked cast of 'Measure<*>' to 'Measure<T>'(observed locally while compiling the clone). Comparing two erasedMeasures of different dimensions viaequalsdoes not fail at compile time and can compare ratio-scaled amounts across dimensions; the static safety guarantee covers the typed operators (+,-,*,/), not reflective/erased paths.
The correct framing: measured buys compile-time dimensional safety at the price of ordinary boxed-object runtime overhead — an ergonomic, not a zero-cost, library.
Diagnostics
The mandated experiment — adding a Length to a Time — compiled against the pinned clone's sources with kotlinc-jvm from nixpkgs:
// locally reproduced — MPlusS.kt, compiled with the measured sources on the classpath
import io.nacular.measured.units.*
import io.nacular.measured.units.Length.Companion.meters
import io.nacular.measured.units.Time.Companion.seconds
fun main() {
val m = 1 * meters
val s = 1 * seconds
val bad = m + s // should not compile
println(bad)
}MPlusS.kt:8:19: error: argument type mismatch: actual type is 'Measure<Time>', but 'Measure<Length>' was expected.
val bad = m + s // should not compile
^[reproduced locally, kotlinc-jvm 2.2.21 (nixpkgs, JRE 21), against measured sources @ 3f65500, 2026-07-04]
The diagnostic is excellent — and this is a genuine differentiator from the exponent-vector systems. Because the dimension is a plain nominal type (Length, Time) rather than a typenum binary encoding, the error names the domain types directly: actual Measure<Time>, expected Measure<Length>. There is no PInt<UInt<UTerm, B1>> mangling (uom's weak flank), no eight-way associated-type record, no truncation-to-file. The mechanism explaining it: plus is Measure<T>.plus(other: Measure<T>) (Units.kt L113), so with T = Length fixed by the receiver, Measure<Time> simply isn't an applicable argument.
The valid same-dimension path compiles and runs, confirming mixed-unit addition and L/T → Velocity inference [reproduced locally, kotlinc-jvm 2.2.21, 2026-07-04]:
// locally reproduced — Valid.kt; prints "2000.0 m" then "500.0 m/s"
val d = 500 * meters + 1.5 * kilometers // OK: same dimension, mixed units -> 2000 m
val v: Measure<Velocity> = d / (4 * seconds)
println(d) // 2000.0 m
println(v `as` meters / seconds) // 500.0 m/sThe library's own order-sensitivity limitation reproduces as a compile error too — UnitsProduct<Angle, Time> is not assignable to UnitsProduct<Time, Angle> (README.md L112–122):
Order.kt:8:40: error: initializer type mismatch: expected 'UnitsProduct<Time, Angle>', actual 'UnitsProduct<Angle, Time>'.
val c: UnitsProduct<Time, Angle> = radians * seconds // should FAIL: order-sensitive
^^^^^^^^^^^^^^^^^[reproduced locally, kotlinc-jvm 2.2.21, 2026-07-04]
This is the flip side of the great m + s message: the same lack of normalization that keeps error types readable also makes A·B ≠ B·A a real, user-visible type error the README must warn about and patch by hand. The in-repo tests corroborate the positive behaviour (the operators are exercised in UnitTests.kt L616–627, plusMinusOperatorsWork), but there is no compile-fail test harness in the repo — the rejection evidence here is the local reproduction, rung 1 of the provenance ladder.
Ergonomics & compile-time cost
The surface is genuinely pleasant. Kotlin's infix functions and operator overloading let the DSL read like maths: 5 * meters / seconds, duration in milliseconds, distance as kilometers (README.md L36–50). Named members off companion objects (Length.meters) and the Number.times(Units) bridge (Units.kt L328, L335) make construction terse. For the library's target domains — time handling, graphics, everyday physical quantities on Kotlin Multiplatform — this is a low-ceremony, high-readability experience, and the error messages (above) are among the most readable in the survey.
Compile-time cost is modest. The model is small (one 429-line core + seven short dimension files), so there is no macro expansion or type-family solving to pay for. A full JVM compile of the entire library plus a small consumer file finishes in ~4.2 s wall (kotlinc-jvm 2.2.21, -include-runtime), most of it compiler/JVM startup rather than type-checking [measured locally, 2026-07-04]. The latent cost is overload-resolution pressure: 110 active operator fun overloads (of 127 operator fun lines, the other 17 commented out) distinguished by @JvmName and generic shape mean that deeply-nested compound expressions can force the resolver to consider many candidates, and — as the // FIXME lines attest — some shapes are ambiguous enough that no overload could be written at all. In practice this bites as missing operators (a compile error telling you the combination is unsupported), not as slow builds.
The ergonomic cliff is compound arithmetic beyond the shipped table. Everyday one- and two-factor expressions are covered; three-plus-factor products, or any product of UnitsProduct operands, may hit a // FIXME hole and require the user to write an extension operator fun (and possibly an order-canonicalizing one, per the README's guidance). The library is optimized for the common case, and explicit about where the common case ends.
Strengths
- Readable, domain-language diagnostics — because dimensions are nominal generic types, a mismatch reads expected
Measure<Length>, actualMeasure<Time>, with notypenum-style encoding or truncation; among the clearest error messages in the survey (Diagnostics). - Tiny, transparent model — one 429-line core; a unit is a
suffix+ratio, a dimension is a class, a compound dimension is a nested generic. Easy to read end to end and to extend. - Trivial user extension of units and dimensions — new members and whole new dimensions are a few lines from user code, no registry, no macro (
README.mdL72–110). - Kotlin Multiplatform reach — the same dimensional machinery runs on JVM, JS, Wasm-JS, and a broad Native matrix (iOS/macOS/watchOS/tvOS/Android-Native/Windows/ Linux), which is the library's real distribution advantage.
- Ergonomic operator DSL — infix
in/as, operator* / + -, and companion-object members make quantity code read mathematically. - Angle as a real dimension — trig is angle-typed, so radians/degrees can't be confused with a bare
Double(Angle.kt).
Weaknesses
- No exponent algebra → order-sensitive, hole-ridden compound types —
A·B ≠ B·A, dimensionless collapses toDouble, and 16UnitsProductcombinations are un-writable// FIXMEgaps (Units.ktL179–186, L281–288). Canonicalization is a per-pair manual chore (Extensibility). - No fractional powers, and none possible — powers are product types; Kotlin lacks type-level integers, so
sqrt/rational exponents are unrepresentable, not just unimplemented. - No affine/temperature support at all —
Unitsholds only a multiplicativeratio; there is noTemperaturedimension, and the README states °C/°F are out of scope (README.mdL144–146). - Not zero-cost at runtime —
Measure<T>is a heap object with a boxedDoubleand aUnitsreference; novalue class/repr(transparent); every op allocates (Zero-cost story). - Erasure soundness gap —
Measure.equalsdoes an unchecked cast (Units.ktL149); reflective/erased paths escape the static guarantee that the typed operators enforce. - No
Kind-style same-dimension disambiguation — torque vs energy, Hz vs Bq, etc., are not modeled; scope is a small dimension set, not a complete ISQ. - No dimensional polymorphism / inference — generic-over-power functions can't be written; there is nothing like Kennedy-style principal types.
Key design decisions and trade-offs
| Decision | Rationale | Trade-off |
|---|---|---|
Dimension = the generic parameter T of Measure<T: Units> | No new machinery — plain generics + operator overloading; excellent nominal-type error messages | JVM erasure leaves no runtime dimension; unchecked-cast soundness gaps on erased paths |
| Compound dimensions as nested generic types, not an exponent vector | Simple, transparent, no reduction engine; readable diagnostics | No normal form: A·B ≠ B·A, dimensionless collapses to Double, cancellation must be enumerated |
Combine dimensions via a hand-written operator fun overload table | Direct, debuggable; each legal shape has an explicit, readable definition | 127 overloads with @JvmName disambiguation; 16 // FIXME un-writable holes; per-pair order canonicalization |
A unit is a suffix + multiplicative ratio only | Tiny model; trivial to add units/dimensions; conversion is one division | Cannot express affine units — no Celsius/Fahrenheit, no Temperature dimension at all |
Measure<T> as an ordinary class (not a value class) | Simplicity; carries the live Units object for printing/conversion | Boxing + allocation per value and per operation; not zero-cost |
| Kotlin Multiplatform, no compiler plugin / annotation processing | One library across JVM/JS/Wasm/Native; fast builds; no codegen step | No type-level power arithmetic or dimensional inference is achievable within the mechanism |
Sources
- nacular/measured — GitHub repository (pinned locally at
$REPOS/kotlin/measured@3f65500, tagv0.5.0, 2026-04-05) src/commonMain/kotlin/io/nacular/measured/units/Units.kt— the entire model:Units,Measure<T>,UnitsProduct/UnitsRatio/InverseUnits, the operator table,// FIXMEgaps,Velocity/AccelerationaliasesLength.kt/Time.kt/Mass.kt/Angle.kt/BinarySize.kt/GraphicsLength.kt— the shipped dimensions and their scale-factor membersAngle.kt— angle as a first-class dimension with degrees/radians and trigBinarySize.kt— decimal (kB) and binary (KiB) storage multiplesREADME.md— positioning, complex-units examples, extensibility, and the "Current Limitations" section (order-sensitivity, linear-only/temperature) ·Module.mdsrc/commonTest/kotlin/io/nacular/measured/units/UnitTests.kt— operator behaviour tests (plusMinusOperatorsWork, L616)- Dokka API documentation site
- Local reproductions (
m + srejection, valid mixed-unit addition +L/T → Velocity, order-sensitivity rejection, unchecked-cast warning, ~4.2 s compile timing): scratch workspace compiling the pinned clone's sources,kotlinc-jvm 2.2.21(nixpkgs, JRE 21), 2026-07-04 - Related deep-dives in this survey: type-system mechanisms · free abelian group · torsors & affine quantities · Kennedy's type system ·
squants·coulomb·uom· Boost.Units · mp-units ·dimensioned· the comparison capstone