coulomb (Scala)
A statically-typed unit-analysis library for Scala 3 in which units are phantom type expressions over base-unit types combined with *, /, and ^ (rational exponent), a Quantity[V, U] is an opaque type equal to its raw value V at runtime, and all dimensional checking runs in a quotes.reflect macro that reduces any unit expression to a canonical base-unit signature at compile time.
| Field | Value |
|---|---|
| Language | Scala 3 only (crossScalaVersions := Seq("3.3.8"), an LTS; JDK 17) |
| License | Apache-2.0 |
| Repository | erikerlandson/coulomb |
| Documentation | erikerlandson.github.io/coulomb · javadoc.io |
| Key authors | Erik Erlandson (erikerlandson, author/maintainer) |
| Category | Library-level compile-time checking (no compiler plugin; a inline + scala.quoted macro DSL) |
| Mechanism | opaque type Quantity[V, U] = V; a macro canonicalizes U to a List[(baseUnitType, Rational)] signature |
| Exponent domain | ℚ — type-level Rational exponents (Meter ^ (1 / 2) is first-class), via spire.math.Rational |
| Checking time | Compile time (implicit search drives macro expansion); zero runtime dimensional representation |
| Analyzed version | 681442a (pinned clone, 2026-06-22; tlBaseVersion := "0.9", post-v0.9.1) |
| Latest release | v0.9.1 (2025-09-03; latest on Maven Central com.manyangled:coulomb-core_3) |
NOTE
coulomb is this survey's data point for opaque-type erasure + a reflective canonicalization macro: a quantity carries no runtime dimension at all (it is its underlying scalar), and compatibility is decided by a hand-written macro that expands both operands to base units and checks the residual cancels — not by the host type-checker evaluating type-level arithmetic (uom, dimensioned) nor by AG-unification (F#, uom-plugin; see Kennedy's type system and the mechanism taxonomy). Its rational type-level exponents put it beside mp-units and F# on the free-abelian-group fractional-power axis. Its Scala sibling squants takes the opposite path — runtime unit values with F-bounded compile-time safety — making the two a clean intra-language contrast. See the comparison capstone.
Overview
What it solves
coulomb gives Scala programs dimensional analysis as a type-system discipline: adding metres to seconds is a compile error, dividing metres by seconds isMeter / Second, and none of it survives into the runtime representation. The crate-root docs frame unit analysis as an extension of the type system itself (docs/coulomb-core.md L34–56):
"Unit analysis performs a role very similar to a type system in programming languages such as Scala. Like data types, unit analysis provides us information about what operations may be allowed or disallowed. Just as Scala's type system informs us that the expression
7 + falseis not a valid expression ... unit analysis informs us that addingmeters + secondsis not a valid computation."
The README.md splash alt-text states the positioning in one line — "coulomb: a statically typed unit analysis library for Scala" (README.md L1). The predefined-units package ships seven SI base units (Meter, Kilogram, Second, Ampere, Mole, Candela, Kelvin — units/.../si.scala L28–53), the SI prefixes, the US/imperial units, information units, temperature, time, physical constants, and the historically-accepted metric units.
Design philosophy
Two decisions define coulomb against the other systems in this survey.
A quantity is its value. Quantity[V, U] is declared as an opaque type aliasing V (core/.../quantity.scala L64):
// coulomb: core/src/main/scala/coulomb/quantity.scala L64
opaque type Quantity[V, U] = VOutside the Quantity object the unit U is a phantom that the compiler tracks but that has no runtime footprint; inside, withUnit and value are inline identity coercions (inline def withUnit[U]: Quantity[V, U] = v — quantity.scala L91, L103). At runtime a Quantity[Double, Meter]is a Double. This is the same erasure story uom achieves with #[repr(transparent)] + PhantomData, reached instead through Scala 3's opaque-type feature.
Units, not quantities, are the primitive — and the checker is a macro, not the type-checker. Where uom normalizes every value to a quantity's base unit eagerly at the boundary, coulomb keeps the unit expression in the type and computes conversion coefficients lazily, at each operation, in a compile-time macro. A unit is a type-level expression — base-unit phantom types combined with the three operator types *, /, ^ (quantity.scala L29, L41, L55) — and the whole dimensional theory lives in one reflective function, cansig, that reduces such an expression to a canonical signature. The author calls this "the fundamental algorithmic unit analysis criterion" and links his own write-up of the algorithm in a source comment (core/.../infra/meta.scala L136–137).
How it works
A unit type is a tree of the phantom operator types over leaves that are either numeric literal constants (1, 1000, 10 ^ 3) or abstract unit types tagged by a given instance of one of three marker classes (core/.../define/define.scala):
BaseUnit[U, Name, Abbv]— a fundamental dimension axis.Meter,Second,Kilogramare each a barefinal typeplus agiven BaseUnitinstance (si.scalaL28–53;define.scalaL43).DerivedUnit[U, D, Name, Abbv]— a unit defined as another unit expressionDtimes a coefficient.LiterisDerivedUnit[Liter, (Meter ^ 3) / 1000, …];KiloisDerivedUnit[Kilo, 10 ^ 3, …](accepted.scala,si.scalaL75–77;define.scalaL72).DeltaUnit[U, D, O, Name, Abbv]— aDerivedUnitcarrying an additive offsetOfor affine scales like temperature (define.scalaL104–113).
There is no separate "dimension" entity: a BaseUnit is a dimension axis, and a quantity's dimension is whatever base-unit signature its unit expression reduces to.
The canonicalization macro (cansig)
Every dimensional decision routes through cansig (meta.scala L168–221), which walks a unit TypeRepr and returns a coefficient plus a canonical signature — a List[(TypeRepr, Rational)] pairing each base-unit type with its net rational exponent. Under SigMode.Canonical it "yields signatures fully expand[ed] down to base units" (meta.scala L52–57): it multiplies coefficients and adds exponents across *, divides and subtracts across /, scales exponents across ^, expands each DerivedUnit by recursing into its definition, and looks up each BaseUnit/DerivedUnit leaf by an Implicits.search for the corresponding given (meta.scala L286–350):
// coulomb: core/src/main/scala/coulomb/infra/meta.scala L190-199 (abridged)
case AppliedType(op, List(lu, ru)) if (op =:= TypeRepr.of[*]) =>
val (lcoef, lsig) = cansig(lu)
val (rcoef, rsig) = cansig(ru)
val usig = unifyOp(lsig, rsig, _ + _) // multiply units => add exponents
(lcoef * rcoef, usig)
case AppliedType(op, List(lu, ru)) if (op =:= TypeRepr.of[/]) =>
val (lcoef, lsig) = cansig(lu)
val (rcoef, rsig) = cansig(ru)
val usig = unifyOp(lsig, rsig, _ - _) // divide units => subtract exponents
(lcoef / rcoef, usig)This is the free-abelian-group model made operational: the signature is a finite map from base-unit generators to ℚ exponents, unifyOp is the group operation, and cancellation to Nil is the identity. Two unit types are convertible iff dividing one by the other canonicalizes to the empty signature (meta.scala L159–165):
// coulomb: core/src/main/scala/coulomb/infra/meta.scala L159-165
def convertible(u1: TypeRepr, u2: TypeRepr): Boolean =
given sigmode: SigMode = SigMode.Canonical
val (_, rsig) = cansig(TypeRepr.of[/].appliedTo(List(u1, u2)))
rsig == NilCoefficients and the compile error
coef (the coefficient the conversion multiplies by) is computed the same way, and it is where the rejection is raised (meta.scala L130–145):
// coulomb: core/src/main/scala/coulomb/infra/meta.scala L130-145 (abridged)
def coef(u1: TypeRepr, u2: TypeRepr): Rational =
if (u1 =:= u2) Rational.one
else
// the fundamental algorithmic unit analysis criterion:
given sigmode: SigMode = SigMode.Canonical
val (rcoef, rsig) = cansig(TypeRepr.of[/].appliedTo(List(u1, u2)))
if (rsig == Nil) then rcoef
else
report.error(
s"unit type ${typestr(u1)} not convertable to ${typestr(u2)}")
Rational.zeroThe macro is reached through implicit search: Coefficient[V, UF, UT] and UnitConversion[V, UF, UT] are typeclasses whose inline given instances call the coefficientDouble/coefficientRational/… splices, which call coef (conversion/coefficients.scala L23–41; conversion/coefficient.scala L60–103; conversion/unit.scala L68–104). If coef calls report.error, the whole implicit resolution fails at the call site. There is a second, simplifying signature mode: operator output types (a * b, a / b, a.pow[E]) are built by SimplifiedUnit[U], a transparent inline given macro that canonicalizes in SigMode.Simplify — which "does not expand derived units, and respects type aliases" (meta.scala L59–63; infra/simplified.scala L28–40) — so Meter * Meter surfaces as Meter ^ 2, not fully expanded.
Dimension representation
A dimension is a canonical signature — a List[(baseUnitType, Rational)] of (base-unit type, net exponent) pairs — computed on demand from a unit type expression (meta.scala L168–221). Three properties distinguish it from the type-level-integer-vector systems:
- Exponents are
ℚ, notℤ. Each signature exponent is aspire.math.Rational; the^operator type takes a type-level rational (Meter ^ (1 / 2),Second ^ -1,quantity.scalaL43–55), andcansighandles a rational power byfpowon the coefficient andunifyPowscaling on the signature (meta.scalaL200–213, L401–408). Half-integer dimensions are representable by construction, the direct opposite of uom/dimensioned'stypenum::Integerwalls. - The base-dimension set is open and un-fixed. Nothing pins the number of base units: a signature is a sparse map, so a
BaseUnit[Scoville, "scoville", "sco"]declared in three lines of user code (define.scalaL36–42) is a new dimension axis on equal footing withMeter. There is no per-system exponent vector whose length must be edited (contrast uom's closedsystem!). - Normalization is by list unification with
=:=term-matching, not by type identity.unifyOp/insertTermfold terms together when their base-unitTypeReprs are=:=, dropping any that cancel to a zero exponent (meta.scalaL379–399). So "same dimension, different spelling" (Meter / SecondvsMeter * (Second ^ -1)) canonicalizes identically — but the cost is that this reduction is recomputed by the macro at every operation, not memoized in a nominal type.
Dimensionless-ness is the empty signature: 1 (and any pure numeric-constant unit) canonicalizes to (coefficient, Nil) (meta.scala L179–186), which is why Meter / Meter and Radian alike reduce to unitless.
Checking & inference
Checking is implicit search that triggers a reflective macro — decidable and terminating (the macro is structural recursion over a finite type tree), never a constraint solve. Binary +/-/comparison require the right operand be converted to the left's unit, so each is defined in terms of UnitConversion[V, UR, U] (quantity.scala L192–196):
// coulomb: core/src/main/scala/coulomb/quantity.scala L192-196
inline def +[UR](qr: Quantity[V, UR])(using
alg: AdditiveSemigroup[V]
): Quantity[V, U] =
val qrv: V = UnitConversion[V, UR, U](qr) // fails to resolve if not convertible
alg.plus(q, qrv)Because the value algebra (AdditiveSemigroup[V], MultiplicativeGroup[V], …) is itself a using parameter from spire/algebra, coulomb inherits Scala's ordinary term inference: val v = d / t infers Quantity[Double, Meter / Second] without annotation, and multiplying builds the output unit via the SimplifiedUnit[U * UR] macro's su.UO member type (quantity.scala L236–240). What it cannot do — like every non-Kennedy system here — is invert unit arithmetic: there is no principal-type generalization that would infer the exponent of a pow from a desired result type.
Dimensional polymorphism is expressible by carrying the same context bounds the concrete operators use. A function generic over a value type V and a unit U can demand SimplifiedUnit[U * U] and a MultiplicativeSemigroup[V] and return Quantity[V, su.UO]; unlike uom's seven-fold typenum where-clauses, the bound is a single SimplifiedUnit, because the exponent arithmetic is hidden inside the macro rather than spelled out per base symbol.
Extensibility
Extension is uniformly "declare a final type and a given", with no macro authoring required — the three-line pattern from the BaseUnit doc comment (define.scala L36–42) is the entire vocabulary:
- A new base dimension —
type Scoville; given BaseUnit[Scoville, "scoville", "sco"] = BaseUnit(). Immediately a full dimensional citizen; no system to edit. - A new derived unit —
type Smoot; given DerivedUnit[Smoot, 67 * Inch, "smoot", "smt"] = DerivedUnit()(define.scalaL63–70). The definition is any unit expression, so units compose transitively (Liter⊂Meter ^ 3,Bit⊂Byte / 8—info.scalaL42–52). - A new affine unit — a
DeltaUnitwith a rational offset (see Expressiveness edges). - Value-type genericity — the same machinery works for any
Vwith the rightspire/algebratypeclasses in scope:Float,Double,BigDecimal,Rational, boxedjava.lang.Float/Doublehave direct coefficient macros, and any otherVis served by summoningFractional[V]plus aValueConversion[Rational, V](conversion/coefficient.scalaL60–103). Integer value types are deliberately not Fractional — see the truncation finding under Diagnostics.
The companion coulomb-runtime module bridges to runtime unit analysis: a RuntimeQuantity[V] pairs a value with a RuntimeUnit AST (UnitConst/UnitType/Mul/Div/Pow — runtime/.../runtime.scala L26–89) so units parsed from strings or config can be converted via a staged mapping at runtime. It is the escape hatch for the one thing the opaque-type core cannot do: decide dimensions not known until runtime. Additional integration modules (coulomb-parser, coulomb-pureconfig, coulomb-refined) layer on top.
Expressiveness edges
- Fractional powers: first-class. Exponents are type-level rationals throughout;
pow[E]dispatches on the value algebra —Fractional[V]"supports all rational exponents",MultiplicativeGroup[V]all integers,MultiplicativeMonoid[V]non-negative integers,MultiplicativeSemigroup[V]positive integers (quantity.scalaL333–363).2d.withUnit[Meter].pow[1 / 2]type-checks and evaluates toQuantity[Double, Meter ^ (1 / 2)][reproduced locally, scala-cli 1.10.1 / Scala 3.3.8, 2026-07-04 — see Diagnostics]; the corresponding in-repo tests assert both the value and theMeter ^ (1 / 2)output type (core/.../test/.../quantity.scalaL190–198). This placescoulombwith mp-units and F# on the free-abelian-group rational-exponent axis. - Affine quantities: a distinct type anchored to a base unit. Points live in
DeltaQuantity[V, U, B](core/.../deltaquantity.scalaL30), a separate opaque type from the linearQuantity, parameterized by the base unitBit is anchored to (Kelvinfor temperature,Second-epoch for time). The affine algebra is enforced structurally:DeltaQuantity - DeltaQuantityyields a linearQuantity(a difference of points is a vector), whileDeltaQuantity ± Quantityyields aDeltaQuantity(point plus vector is a point —deltaquantity.scalaL145–197). There is noDeltaQuantity + DeltaQuantity. The offset lives in the type:FahrenheitisDeltaUnit[Fahrenheit, (5 / 9) * Kelvin, 45967 / 100, "fahrenheit", "°F"]andCelsiuscarries offset27315 / 100(units/.../temperature.scalaL27–44), extracted by the macro'soffsetfunction (meta.scalaL147–157). This is a concrete, general instance of the torsor / affine-quantity model — not restricted to temperature the way uom's is;EpochTimereuses the identical machinery for timestamp-vs-duration. - Logarithmic quantities: absent. There is no decibel, neper, or level-of-power/field unit anywhere in the units package (verified by search of the pinned clone) — dB is out of scope, as in most systems here (Pint is the exception).
- Angles: dimensionless, with no kind tag to keep them apart.
RadianisDerivedUnit[Radian, 1, "radian", "rad"]— literally derived from the constant1(units/.../mks.scalaL39–40) — andDegreeisDerivedUnit[Degree, 3.141592653589793 / 180, "degree", "°"](accepted.scalaL47–49). So radians, degrees, and pureRatioall share the empty signature and are freely inter-convertible;coulombdoes not treat angle as a quantity distinct from a dimensionless number. - Kind-vs-dimension disambiguation: not modeled.
coulombhas noKindmechanism (verified: noKindtype anywhere incore/src/main). Same-dimension quantities that differ only in intent — torque vs energy (bothL²MT⁻²), frequency vs becquerel (bothT⁻¹), angle vs ratio — are the samecoulombtype and interconvert silently. This is the clearest capability gap against uom'sKindand F#'s measure-name distinctions:coulomb's dimension algebra is purely the free abelian group over base units, with no orthogonal tag layer. The trade-off is a simpler, tag-free model with no kind-erasure surprises under multiplication (the failure mode uom has); the price is that the torque/energy and Hz/Bq collisions are unguarded.
Zero-cost story
The "zero runtime cost" claim is structural, resting on Scala 3's opaque types and inline, and there is no benches/ proving it against hand-written scalar code:
opaque type Quantity[V, U] = V(quantity.scalaL64): outside the defining scope the two types are distinct; at runtime they are erased to the same representation. AQuantity[Double, Meter]is aDouble— no wrapper object, no boxed dimension, noPhantomData-equivalent field (there isn't even a field; the value is the quantity).DeltaQuantity[V, U, B]is likewise opaque overV(deltaquantity.scalaL30).- Constructors/extractors are
inlineidentities —withUnit,value,withDeltaUnitcompile to the value itself (quantity.scalaL91, L103;deltaquantity.scalaL54, L68). - Conversion coefficients are compile-time constants, inlined. For the primitive value types the coefficient is spliced in as a literal by the
coefficientDouble/coefficientRational/… macros (coefficients.scalaL23–41), andUnitConversion.applyshort-circuits the identity case entirely (if (typeexpr.uniteq[UF, UT]) v—conversion/unit.scalaL68–69) so same-unit arithmetic carries no multiply at all. Steady-statel1 + l2on the same unit is a barealg.plus. - The cost is moved to compile time. Every distinct unit operation triggers a
cansigtraversal and an implicit search; the macro engine, not the runtime, pays. This is the mirror image of a runtime-checked library likesquants, and the same trade uom makes — the bill is compile time and diagnostics, not cycles.
The honest caveat is representational rather than performance: because arithmetic runs in the raw value type, using a non-Fractional storage type (e.g. Int) makes any lossy conversion a compile error rather than a silent truncation (next section) — a safety feature, but one that means integer quantities cannot convert across scale factors at all.
Diagnostics
The mandated experiment — adding a Length to a Time — reproduced against the published v0.9.1 artifact (the pinned clone's HEAD), value type Double:
// locally reproduced — mplusS.scala
//> using scala 3.3.8
//> using dep com.manyangled::coulomb-core:0.9.1
//> using dep com.manyangled::coulomb-units:0.9.1
import coulomb.*
import coulomb.syntax.*
import algebra.instances.all.given
import coulomb.units.si.{*, given}
@main def run(): Unit =
val bad = 1d.withUnit[Meter] + 1d.withUnit[Second]
println(bad.show)[error] ./mplusS.scala:11:15
[error] unit type Second not convertable to Meter
[error] val bad = 1d.withUnit[Meter] + 1d.withUnit[Second]
[error] ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
[error] Error compiling project (Scala 3.3.8, JVM (21))[reproduced locally, scala-cli 1.10.1 (nixpkgs), Scala 3.3.8 / JVM 21, 2026-07-04]
This is the single most striking contrast in the survey. The error is written in the domain's language, not the implementation's — "unit type Second not convertable to Meter", the exact string from report.error (meta.scala L142–143), rendered by typestr, whose "policy goal ... is that type aliases are never expanded" (meta.scala L424–426). No typenum binary encoding, no alphabetized associated-type record, no truncation-to-file — where uom dumps PInt<UInt<UTerm, B1>> to a side file, coulomb names the offending units. (The report points at the whole expression rather than the second operand, and the message says "convertable" — a spelling quirk carried verbatim in the source.)
The in-repo compile-fail tests corroborate the rejection as rung-2 evidence: the munit suite asserts assertCE("1d.withUnit[Meter] + 1d.withUnit[Second]") ("non convertible units should fail") (quantity.scala L122–123), with the sibling cases for -, unit conversion, and comparison at quantity.scala L141, L86–87, L256. The Coefficient and UnitConversion typeclasses additionally carry @implicitNotFound messages ("No coefficient could be derived …", "No unit conversion in scope …") for the cases the macro doesn't reach (conversion/coefficient.scala L32–35, conversion/unit.scala L31–34).
A second, subtler rejection: lossy integer conversion is a compile error, not a truncation. assertCE("1.withUnit[Meter] - 1.withUnit[Yard]") and its Long counterpart fail (quantity.scala L143–145) because Int/Long have no Fractional instance, so no non-trivial coefficient can be summoned — coulomb refuses the silent rounding that uom's eager integer normalization performs (1 cm → 0 m). Same-unit integer arithmetic still works via the uniteq short-circuit.
The valid path, reproduced against the same artifact [reproduced locally, scala-cli 1.10.1 / Scala 3.3.8, 2026-07-04]:
// locally reproduced — valid.scala (excerpt)
val d = 100d.withUnit[Meter]
val t = 9.58d.withUnit[Second]
val v = d / t // inferred Quantity[Double, Meter / Second]
println(v.show) // => "10.438413361169102 m/s"
val a = 2d.withUnit[Meter].pow[1 / 2]
println(a.show) // => "1.4142135623730951 m^(1/2)"Note the fractional-power result renders in domain language too — 1.4142135623730951 m^(1/2) — because show uses the same ShowUnit type-to-text macro that produces the abbreviations (core/.../io/show.scala L28–44).
Ergonomics & compile-time cost
The surface is minimal for the common case. Using the shipped SI is import coulomb.*; import coulomb.syntax.*; import coulomb.units.si.{*, given} plus a value-algebra import (algebra.instances.all.given or a spire std import); constructing is 100d.withUnit[Meter]. Defining any new unit is a final type + a one-line given — no macro to author, in sharp contrast to uom's three-macro system!/quantity!/unit! architecture or the template metaprogramming of the C++ systems.
Error readability is a genuine strength, as the reproduction shows — arguably the most readable diagnostics of any type-level system in this survey, because the rejection message is generated by library code (report.error + typestr) rather than surfaced by the host type-checker's generic mismatch machinery. The costs are elsewhere:
- Scala 3 only.
coulomb's macro engine is built entirely onscala.quoted/quotes.reflectand opaque types, so there is no Scala 2 line (crossScalaVersions := Seq("3.3.8")—build.sbtL44). A consumer must be on Scala 3. - Compile-time macro expansion per operation. Every distinct unit operation runs an implicit search that expands a
cansigtraversal and (for outputs) aSimplifiedUnitmacro; unit-heavy code pays a compile-time tax that scales with the number and depth of distinct unit expressions. (No wall-clock figure is reproduced here; the mechanism — reflective macro per operation — is the finding.) - Dependency surface. The core pulls in
spire,algebra, andcats-kernelfor the numeric-tower typeclasses that parameterize every operation (build.sbt) — heavier than a self-contained library, in exchange for working uniformly over the entire Typelevel numeric ecosystem.
Strengths
- Domain-language diagnostics — "unit type Second not convertable to Meter", produced by library code, not the compiler's generic mismatch; the most readable errors of the type-level systems surveyed, and a direct answer to uom's weakest flank. Shown in Diagnostics.
- True zero-runtime dimensions —
opaque type Quantity[V, U] = Vplusinlineeverywhere; a quantity is its scalar, verified by construction (no field to carry a dimension). - First-class rational exponents — type-level
Rationalpowers, soMeter ^ (1 / 2)andV/√Hz-style dimensions are ordinary types;sqrtis total, not the even-exponent-only partial function of the integer-vector libraries. - Open base-dimension set, uniform extension — any
final type+given BaseUnit/DerivedUnit/DeltaUnitis a first-class unit; no macro authoring, no fixed exponent-vector length to edit. - General affine model —
DeltaQuantity[V, U, B]with a type-level offset gives point-vs-vector semantics for any anchored scale (temperature, epoch time), not a temperature special case. - Runtime escape hatch —
coulomb-runtime'sRuntimeQuantity/RuntimeUnitbridges to units-known-only-at-runtime (parsing, config), which a pure opaque-type core cannot. - Integer safety — lossy conversions on non-
Fractionalstorage are compile errors, not silent truncation.
Weaknesses
- No kind/quantity-name layer — torque vs energy, frequency vs becquerel, angle vs ratio are the same type; the dimension algebra is the bare free abelian group with no orthogonal tag, so those classic collisions are unguarded (contrast uom's
Kind). See Expressiveness edges. - Scala 3 only — no Scala 2 support at all; the whole engine is
scala.quoted+ opaque types. - Compile-time macro cost — dimensional work is redone by a reflective macro at every operation rather than memoized in a nominal type; unit-heavy code carries a compile-time tax.
- No logarithmic units — no decibel/neper support.
- Heavier dependency graph —
spire+algebra+cats-kernelare pulled in for the value-algebra typeclasses that gate every operator. - Normalization is not free-of-recompute — canonicalization by list unification is recomputed per operation, where an integer-vector library's structural type identity is decided once (its own, different, cost being unreadable type names).
Key design decisions and trade-offs
| Decision | Rationale | Trade-off |
|---|---|---|
opaque type Quantity[V, U] = V | Zero runtime footprint — a quantity is its scalar; no wrapper, no dimension field | Erasure means all dimensional work must happen at compile time in a macro; Scala 3-only (opaque types) |
| Units-in-the-type + lazy per-op coefficient (vs eager-to-base) | Unit identity is preserved; conversions computed only where needed; rational coefficients kept exact | The cansig canonicalization macro reruns on every operation instead of a one-time normalization |
A hand-written quotes.reflect macro decides compatibility | Full control of the algorithm and the error text — "unit type X not convertable to Y" in domain language | A bespoke macro engine (meta.scala) to maintain; reflection-heavy; no reuse of the host type-checker |
Type-level Rational exponents | Fractional powers first-class; sqrt/cbrt total; half-integer dimensions representable | Exponent arithmetic needs a Rational type-level encoding and spire at compile time |
No Kind/quantity-name tag — pure base-unit group | Simplest possible model; no kind-erasure-under-multiplication surprises | Torque/energy, Hz/Bq, angle/ratio collisions are unguarded — same-dimension intents are indistinguishable |
Value algebra as using params from spire/algebra | One library works over Float…BigDecimal…Rational and any Typelevel numeric type; inherits inference | Heavy dependency graph; Int/Long (non-Fractional) can't do lossy conversions (a safety win, a limit) |
Affine scales as a separate DeltaQuantity[V, U, B] opaque type | Point-vs-vector algebra enforced by types for any anchored scale (temperature, epoch time) | A parallel type and operator set to the linear Quantity; the base-unit anchor B must be threaded through |
Sources
- erikerlandson/coulomb — GitHub repository (pinned locally at
$REPOS/scala/coulomb@681442a, 2026-06-22) core/.../coulomb/quantity.scala— theopaque type Quantity, operator and ordering extensions,powcore/.../coulomb/infra/meta.scala— the macro engine:cansig,coef,convertible,offset,typestr; where compatibility is decided and the compile error raisedcore/.../coulomb/define/define.scala—BaseUnit/DerivedUnit/DeltaUnitmarker classescore/.../coulomb/conversion/{coefficient,unit,coefficients}.scala— theCoefficient/UnitConversiontypeclasses and coefficient splice macroscore/.../coulomb/infra/simplified.scala—SimplifiedUnitoperator-output-type macrocore/.../coulomb/deltaquantity.scala— the affineDeltaQuantityopaque type and its point/vector algebraunits/.../coulomb/units/{si,accepted,temperature,info,mks}.scala— base units, angle, temperature offsets, information unitsruntime/.../coulomb/runtime/runtime.scala—RuntimeQuantity/RuntimeUnitfor runtime unit analysiscore/src/test/.../coulomb/quantity.scala—assertCEcompile-fail tests (m + s, lossy-int, fractional-power)docs/coulomb-core.md— "unit analysis performs a role very similar to a type system" positioning ·README.md·build.sbt— Scala 3.3.8, JDK 17,spire/algebradeps- Local reproductions (m+s error, valid ops, fractional power): scratch scala-cli project against
com.manyangled:coulomb-{core,units}_3:0.9.1,scala-cli 1.10.1/ Scala3.3.8/ JVM 21, 2026-07-04 - coulomb concept docs · javadoc.io API · Maven Central
- Related deep-dives in this survey: type-system mechanisms · Kennedy's type system · free abelian group · torsors & affine quantities ·
squants· uom ·dimensioned· F# units of measure ·uom-plugin· mp-units · Pint · the comparison capstone