Motivation
Physical quantities — distances, durations, file sizes, temperatures — are everywhere in real software. The most common way to represent them in Dart is as plain double or int values, with the unit implied by a variable name or a comment.
This works until it doesn't.
The problem with raw numbers
Consider adding two speeds together:
// With plain doubles there is nothing to prevent mixing incompatible values.
// The compiler cannot catch either mistake:
void rawProblem() {
const speedMph = 60.0; // miles per hour
const speedKph = 100.0; // kilometers per hour
const combined = speedMph + speedKph; // wrong — silently adds apples to oranges
const distanceMeters = 1000.0;
const distanceKilometers = 5.0;
// Forgot that units differ — off by a factor of 1000
const total = distanceMeters + distanceKilometers;
}Both bugs compile and run without error. The type of combined and total is just double — the compiler has no idea that the values come from different unit systems. Errors like these famously brought down the Mars Climate Orbiter (a unit mismatch between pound-force seconds and newton seconds).
Comments and naming conventions are the only defence, and they are not enforced.
The solution: units as types
ribs_units represents every quantity as a value and a unit together. The type of a length is Length, the type of a velocity is Velocity, and they cannot be mixed:
// ribs_units makes the unit part of the type.
// Mixing incompatible quantities is a compile-time error,
// and conversions are explicit and readable.
void typedSolution() {
final speedA = 60.usMilesPerHour;
final speedB = 100.kilometersPerHour;
// Convert to a common unit before combining — always explicit.
final combined = speedA + speedB.toUsMilesPerHour;
final distanceM = 1000.meters;
final distanceKm = 5.kilometers;
// No silent unit mismatch — both are Length, conversions happen inside.
final total = distanceM + distanceKm; // 6000 meters
}Adding usMilesPerHour and kilometersPerHour without conversion is still a compile-time error — they are both Velocity, so the + operator compiles, but the values are internally aligned to a common base unit. The conversion is automatic and correct. Length and Velocity cannot be added at all.
Conversions
Every quantity can be converted to any compatible unit using .to(unit) (which returns a double) or typed convenience getters (which return the same quantity type):
void conversions() {
final distance = 10.kilometers;
// Convert with .to(unit) — returns a plain double.
final inMeters = distance.to(Length.meters); // 10000.0
// Or use a typed convenience getter that returns the same quantity type.
final inMiles = distance.toUsMiles; // Length in US miles
// Comparisons work across units automatically.
final a = 5280.feet;
final b = 1.usMiles;
print(a.equivalentTo(b)); // true
print(a >= b); // true
}Comparison operators (>, <, >=, <=) and equivalentTo all convert internally, so 5280.feet >= 1.usMiles evaluates correctly without any manual conversion.
Dimensional arithmetic
Some operations between quantity types produce a different quantity type. ribs_units encodes this at the type level:
void arithmetic() {
// Length × Length → Area (dimensional arithmetic is type-checked)
final width = 10.meters;
final height = 5.meters;
final area = width * height; // Area(50, squareMeters)
// Area × Length → Volume
final depth = 2.meters;
final volume = area * depth; // Volume(100, cubicMeters)
// Area / Length → Length
final side = area / height; // Length(10, meters)
}Length * Length returns Area. Area * Length returns Volume. Area / Length returns Length. These relationships are part of the API — if you try to multiply two Area values and assign the result to a Length, the compiler will tell you.
Information sizes
Information covers both metric (1000-based) and binary (1024-based) unit families as distinct units, so the difference between a marketing gigabyte and a memory gibibyte is always explicit:
void information() {
final fileSize = 1500.megabytes;
// toCoarsest finds the most readable unit automatically.
print(fileSize.toCoarsest); // 1.5 gigabytes
// Metric (1000-based) vs binary (1024-based) are distinct unit families.
final memoryUsed = 1.0.gibibytes; // exactly 1 GiB
final inMB = memoryUsed.toMegabytes; // ~1073.74 MB (metric megabytes)
}toCoarsest converts a quantity to the largest unit in which it has a whole (or near-whole) value — useful for human-readable output.
Temperatures
Temperature is special: converting between scales (e.g. Celsius to Fahrenheit) involves a zero-point offset, but converting a temperature difference (a delta) does not. ribs_units exposes both:
void temperature() {
// Scale conversions (Celsius ↔ Fahrenheit ↔ Kelvin) account for zero-point offsets.
final boiling = 100.celcius;
print(boiling.toFahrenheit); // Temperature(212.0, fahrenheit)
print(boiling.toKelvin); // Temperature(373.15, kelvin)
// Degree conversions (deltas) omit the zero-point offset.
// A 10 °C increase is an 18 °F increase, not 50 °F.
final delta = 10.celcius;
print(delta.toCelsiusDegrees); // Temperature(10.0, celcius) — no offset
print(delta.toFahrenheitDegrees); // Temperature(18.0, fahrenheit) — 10 × 9/5
}toFahrenheit uses the full scale conversion (multiply and add offset). toFahrenheitDegrees treats the value as a delta — no offset, just the 9/5 ratio. Using the wrong one when computing a temperature change is a common source of subtle bugs; having both named explicitly removes the ambiguity.
Parsing
Quantities can be parsed from strings — useful for reading configuration files or user input. The result is Option<A>, making the possibility of an invalid input explicit in the type:
void parsing() {
// Parse quantities from user input or configuration files.
final len = Length.parse('15.5 km');
final info = Information.parse('1024 MiB');
// Returns Option<A> — absence is explicit, not a thrown exception.
len.fold(
() => print('invalid'),
(l) => print(l.to(Length.meters)), // 15500.0
);
// Use getOrElse for a sensible default.
final size = Information.parse('bad input').getOrElse(() => 0.bytes);
}