Skip to content

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:

dart
// 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:

dart
// 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):

dart
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:

dart
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:

dart
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:

dart
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:

dart
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);
}