F# Units of Measure: Compile-Time Dimensional Safety
F# units of measure attach compile-time physical dimensions to numeric types, turning silent unit-mismatch bugs into compiler errors. See a kinetic-energy example, understand runtime erasure, and adopt them on API boundaries.
20 Jun 2026, 01:26 UTC

Adding 10.0 to 5.0 is legal in most languages even when one value represents meters and the other seconds. The compiler sees only floating-point numbers. The mismatch surfaces later as a wrong simulation result, a mispriced trade, or a failed control loop—long after the code looked correct.
F# units of measure solve this by attaching physical dimensions to numeric types at compile time. You annotate values and function signatures with measures such as kg, m, or m/s. The compiler tracks those dimensions through arithmetic and rejects expressions that mix incompatible units. The annotations vanish at runtime, so there is no performance penalty.
Declaring units and letting the compiler do the bookkeeping
You declare a unit with a type abbreviation marked by the [<Measure>] attribute. The body is empty; the name is what matters.
type [<kg>] kg
type [<m>] m
type [<s>] s
Apply a measure to a literal with angle brackets: 10.0<kg>. Apply it to a type in a function signature: float<m/s>. From there, the compiler infers dimensions through multiplication and division. Multiplying a speed by a time yields a distance automatically.
let distance (speed: float<m/s>) (time: float<s>) = speed * time
// Inferred return type: float<m>
You do not annotate every intermediate value. The compiler propagates units through expressions, so a single annotation on the inputs is often enough to constrain the whole formula.
Worked example: kinetic energy
Consider a physics function computing kinetic energy as 0.5 * mass * velocity². Without units, a caller can pass a time where a distance is expected and the compiler stays silent. With units, the signature becomes a contract.
let kineticEnergy (mass: float<kg>) (velocity: float<m/s>) =
0.5 * mass * velocity * velocity
// Inferred return type: float<kg m^2/s^2>
Now try to add mass directly to velocity inside the function body:
let bad = 10.0<kg> + 5.0<m/s>
The compiler rejects this because the units do not match. It also rejects passing a float<s> where float<m> is expected. These are not warnings you can ignore; they are type errors that stop the build.
To see the inference in action, create an .fsx script and run it in F# Interactive from a terminal with dotnet fsi. This requires the .NET SDK and standard user permissions; no administrative rights are needed. Define the measures, then evaluate:
let d = 10.0<m/s> * 2.0<s>
Inspect the interactive output or hover over d in your editor to confirm the inferred type is float<m>. Next, intentionally add incompatible values and observe the compiler error. This confirms the check is active before you rely on it.
Runtime erasure and interop friction
Units of measure are a compile-time construct. They do not appear in the compiled IL (intermediate language), in reflection metadata, or in serialized JSON. A function compiled from float<kg> -> float<m/s> -> float<kg m^2/s^2> is, at the bytecode level, a function on plain float values.
This erasure is what gives the feature zero runtime cost, but it creates friction at boundaries. When you call a plain .NET library that returns float, the result is untyped. You must manually annotate it:
let mass = library.GetMass() * 1.0<kg>
Serialization has the same gap. If you serialize a float<m> to JSON, the unit tag is lost. External contracts must document expected dimensions separately, and deserialization code must reapply measures explicitly.
Adopt units where the risk is highest
Annotating every literal in a large codebase adds noise without proportional safety. The practical strategy is to apply units on public API boundaries and inside core domain formulas—the places where a dimensional mistake is most expensive and hardest to debug.
Start with one safety-critical calculation, such as the kinetic-energy function above. Add measures to its inputs and outputs. Let the compiler verify the internal arithmetic. Then expand outward to the callers that feed that function.
One limitation to plan for: deeply nested derived units can produce verbose signatures. If a function returns float<kg m^2/s^2> and callers struggle to read it, introduce a module alias or a named derived measure to keep the API legible. Also remember that units are compile-time only; they do not validate ranges or magnitudes at runtime. A negative mass or an absurd velocity still passes the type checker.
To confirm runtime erasure, compile a small F# project that uses measures and open the resulting assembly with ildasm or ILSpy. You need read access to the output .dll. Search for the function signature and verify that the parameter and return types appear as plain float64 with no measure annotations. This is expected: units exist only in the compiler's type system.
If you work with serialization or external libraries, treat the boundary as the place where units must be re-established by hand. Document the expected dimensions in your API contracts, and write a small test that round-trips a value through the boundary to ensure the reapplication logic is correct. The syntax described here reflects long-standing F# behavior; exact compiler error messages may vary by version.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.