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 defense, 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 requires no manual conversion — they are both Velocity, so the + operator compiles, and 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.celsius;
  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.celsius;
  print(delta.toCelsiusDegrees); // Temperature(10.0, celsius)  — 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);
}