Stopping Unit-Conversion Bugs with F# Units of Measure
Learn how to use F# Units of Measure to prevent common unit-conversion bugs. This guide covers compile-time dimensional analysis, runtime overhead, and C# interoperability.
02 Sept 2025, 22:32 UTC

The Cost of a Missing Conversion
In scientific and financial software, a common class of bugs occurs when two numeric values are treated as the same type—usually float or decimal—despite representing different physical dimensions. Adding a value in milliseconds to a value in seconds, or adding USD to EUR without a conversion rate, results in a logically incorrect number that the compiler happily accepts. These errors often remain hidden until they cause a production failure or a calculation anomaly.
F# solves this by treating units of measure as first-class citizens in the type system. Instead of relying on variable naming conventions like distanceInMeters, you can annotate the type itself. The compiler then enforces dimensional analysis, ensuring you cannot add meters to seconds or forget to divide by a conversion factor.
How Units of Measure Work
Units of Measure are compile-time annotations. When you define a measure, you are telling the F# compiler that a specific numeric type carries a physical dimension. Because these annotations are erased during compilation, they introduce zero runtime overhead; the resulting Common Intermediate Language (CIL) contains standard floating-point numbers.
The system supports derived units automatically. If you multiply a float<meter> by a float<second>, the result is a float<meter second>. If you divide them, you get float<meter/second>. This allows the compiler to track complex physics or financial formulas without requiring the developer to manually define every possible composite unit.
Implementation Example: A Simple Physics Engine
To use units of measure, you first define the dimensions using the [<Measure>] attribute. This tells F# that the type represents a unit rather than a standard data type.
// Define the base measures
[<Measure>] type meter
[<Measure>] type second
[<Measure>] type kilogram
// Define a conversion factor for kilometers
let kmToM = 1000.0
[<Measure>] type kilometer
// A function to calculate velocity
let calculateVelocity (distance: float<meter>) (time: float<second>) =
distance / time
// Usage
let dist = 100.0
let time = 10.0
let velocity = calculateVelocity dist time // Result is float<meter/second>
// This line would cause a COMPILE-TIME ERROR:
// let error = dist + time
Verification and Testing
You can verify this behavior using the F# Interactive (dotnet fsi) tool. If you attempt to add 1.0<meter> + 1.0<second>, the compiler will throw a type mismatch error. To verify the runtime overhead, you can inspect the compiled DLL using ildasm; you will find that the float<meter> is represented simply as a float64.
Engineering Trade-offs and Limitations
While powerful, Units of Measure have specific boundaries that engineers must manage:
- Serialization Boundaries: Units of Measure exist only in the F# compiler. When data is serialized to JSON or stored in a SQL database, the unit information is lost. You must implement manual validation or use wrapper types when reading data from external sources to ensure the incoming
floatis actually in the expected unit. - C# Interoperability: C# does not recognize F# measure annotations. If you expose a function from an F# library to a C# project, the C# consumer will see a standard
doubleordecimal. To maintain safety, it is recommended to provide C#-specific wrapper methods or use aValueObjectpattern for public-facing APIs. - Value Validation: This feature prevents dimensional errors, not value errors. The compiler will not stop you from assigning a negative value to a
float<meter>, even if a negative distance is physically impossible in your domain.
Practical Decision Matrix
When deciding whether to implement Units of Measure in your project, consider the following:
| Scenario | Recommendation | Reasoning |
|---|---|---|
| Purely internal F# logic | Always Use | Zero cost, high safety. |
| Public API for C# users | Use Internally | Map to standard types at the boundary. |
| High-frequency JSON I/O | Use with Caution | Requires strict schema validation at the edge. |
Closing Action
To begin integrating this into your workflow, identify the three most common numeric conversions in your domain (e.g., currency, time intervals, or physical measurements). Define them as [<Measure>] types and update your function signatures. By moving the conversion check from a unit test to the compiler, you eliminate an entire category of runtime regressions.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.