Stop Adding Metres to Seconds: F# Units of Measure in a Projectile Calculator
F# Units of Measure annotate numeric types with dimensions like metres and seconds, turning unit mismatches into compile-time errors. A projectile calculator shows how velocity, time and distance stay typed end-to-end.
02 Jun 2026, 17:39 UTC

Mixing metres and seconds in a physics calculation compiles fine in most languages and fails quietly at runtime. F# Units of Measure attach dimension information to numeric types so the compiler rejects a metre-plus-second addition before you run anything.
Units of Measure are a compile-time feature. A measure is a phantom annotation on a float or int, like float<m>. The annotation disappears at runtime, but the type checker keeps it.
Defining measures and using them on values
A measure is declared with the [] attribute. Measures compose with multiplication, division and powers, giving you derived dimensions such as velocity.
[<Measure>] type m
[<Measure>] type s
[<Measure>] type kg
let distance : float<m> = 100.0<m>
let time : float<s> = 9.8<s>
let mass : float<kg> = 2.5<kg>
The suffix <m> is the unit annotation. You can also write 100.0 * 1.0<m>. Operations are allowed only when dimensions match. Adding two float<m> values is fine. Adding float<m> to float<s> is a compile-time error.
Derived measures are written with operators:
type velocity = m / s
let v : float<velocity> = 20.0<m/s>
let t : float<s> = 3.0<s>
let d = v * t // float<m>
The compiler infers v * t as metres because (m/s) * s = m. No runtime conversion happens.
Projectile distance with typed units
Consider a simple horizontal projectile: distance = initial velocity * time. With units, the signature documents the intent and blocks misuse.
[<Measure>] type m
[<Measure>] type s
type velocity = m / s
let projectileDistance (v0: float<velocity>) (t: float<s>) : float<m> =
v0 * t
let v0 = 25.0<m/s>
let tFlight = 4.0<s>
let range = projectileDistance v0 tFlight
The function only accepts a velocity and a time and returns a distance. If you accidentally pass a distance where a time is expected, the call fails to compile.
An intentional mistake illustrates the guard:
// let bad = v0 + tFlight // compile error: cannot add m/s and s
To check the behavior locally, create a new F# console project where the .NET SDK is installed and you have write permission in the working directory:
dotnet new console -lang F# -n UnitsDemo
cd UnitsDemo
Replace Program.fs with the snippets above, keeping the erroneous line commented. Build with:
dotnet build
Expected check: the project builds when the mismatched addition is commented out. Uncommenting the line should cause the compiler to reject the file. Comment it back out to restore a buildable state.
Trade-offs and interop limits
Measures add syntactic overhead. Every literal needs a unit annotation or a conversion, and generic inline code can require explicit measure annotations to help inference.
Measures are erased at runtime. The CLR sees plain float. When you expose a value with a measure to C# or an untyped .NET API, the unit information is lost. You must document the expected unit or wrap the value in a nominal type.
Complex physics code with many derived units can become verbose. The benefit is strongest at module boundaries where units enter and leave the system.
A practical way to verify your usage is to build the project with dotnet build and confirm that a deliberate unit mismatch is rejected, then remove the mismatch and confirm the project builds again. This compile-time check is the safety net.
Use Units of Measure for calculations where a silent unit error would be costly, keep the public surface documented for interop, and accept the annotation cost as a form of machine-checked documentation.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.