@vetwo/units
v0.0.2
Published
Generic, domain-agnostic scientific unit & dimensional-analysis engine (Mass, Time, Energy, Currency, Count). No knowledge of nutrition, feed, or any other domain.
Maintainers
Readme
@vetwo/units
A small, dependency-free scientific unit & dimensional-analysis engine for TypeScript.
It represents every unit as a vector of exponents over five orthogonal base
dimensions — Mass, Time, Energy, Currency, Count — and lets you multiply,
divide, add, subtract, and convert Quantity values with full dimensional
safety, so incompatible units can never silently combine.
@vetwo/units is completely domain-agnostic — it has no knowledge of
nutrition, feed, chemistry, or finance. It is the foundation that domain
packages (like @vetwo/nutrition-units)
build on top of.
import { Quantity } from "@vetwo/units";
const cp = Quantity.of(12, "%");
const intake = Quantity.of(10, "kg/day");
intake.multiply(cp).to("g/day"); // Quantity(1200, "g/day")One generic operation — Quantity.multiply() — correctly handles percent,
ppm, mg/kg, Mcal/kg, IU/kg, and any other ratio/rate unit, because it does
real dimensional analysis instead of special-casing each unit type.
Table of contents
- Installation
- Core concept: dimensions
- Built-in units
- API reference (every exported function/class)
- Extending the engine for a new domain
- Design principles
Installation
npm install @vetwo/units
# or
bun add @vetwo/unitsNo runtime dependencies. Requires TypeScript ^5.x for full type support (plain JS consumers can still use it — types are just erased).
Core concept: dimensions
Every unit is described by a dimension vector — exponents over five base dimensions:
| Symbol | Dimension | Example units |
|---|---|---|
| M | Mass | kg, g, mg, µg, lb |
| T | Time | day, hour |
| E | Energy | Mcal, Kcal, MJ |
| C | Currency | cur |
| N | Count / biological activity | IU |
| (empty vector) | Dimensionless ratio | %, ppm, fraction, and any compound that cancels out (e.g. mg/kg) |
Composite units are parsed generically as "<numerator>/<denominator>".
The parser looks up each side as an atomic unit and combines their
dimension vectors and conversion factors — so "Mcal/kg", "IU/day",
"cur/kg", "mg/kg" all work automatically with zero special-casing.
Crucially, if a compound's dimensions cancel out entirely (mass ÷ mass, as
in mg/kg), the parser recognizes this and treats it as a plain ratio —
identical in kind to % or ppm, just with a different scale factor.
Built-in units
Mass: kg, g, mg, µg, lb
Time: day, hour
Energy: Mcal, Kcal, MJ
Currency: cur (generic — map to your real currency symbol upstream)
Count: IU
Ratio: fraction, %, ppmAny "A/B" combination of the above (or of units you register yourself)
works immediately without extra configuration.
Basis tags
A unit string may carry a trailing basis tag: "% DM", "Mcal/kg DM",
"IU/kg asFed". This is stored as metadata (unit.basis) and does not
participate in dimensional math — converting between reporting bases (e.g.
dry-matter vs as-fed) needs an extra domain input (a moisture percentage)
and is therefore left to domain packages, not this engine.
API reference
Quantity
The central value object of the whole package — an immutable { value, unit }
pair with dimensionally-safe arithmetic. Every calculation in an application
built on this package should flow through Quantity instances.
class Quantity {
static of(value: number, unit: string | Unit, registry?: UnitRegistry): Quantity;
to(unit: string | Unit, registry?: UnitRegistry): Quantity;
toBase(): Quantity;
add(other: Quantity): Quantity;
subtract(other: Quantity): Quantity;
multiply(other: Quantity): Quantity;
divide(other: Quantity): Quantity;
scale(factor: number): Quantity;
equals(other: Quantity, epsilon?: number): boolean;
hasSameDimension(other: Quantity): boolean;
toString(): string;
readonly value: number;
readonly unit: Unit;
}Quantity.of(value, unit)
Creates a new quantity. unit can be a string ("kg", "Mcal/day") or an
already-resolved Unit object.
const weight = Quantity.of(450, "kg");
const rate = Quantity.of(2.1, "kg/day");.to(unit)
Converts to another unit of the same dimension. Throws
ImpossibleConversionError if the dimensions differ, or ConversionError
if the two units have conflicting basis tags (e.g. "% DM" → "%"
directly — use a domain BasisConverter for that instead).
Quantity.of(1, "kg").to("g"); // Quantity(1000, "g")
Quantity.of(3000, "Kcal").to("Mcal"); // Quantity(3, "Mcal").toBase()
Returns the value expressed in the canonical base unit of its dimension (kg for Mass, day for Time, Mcal for Energy, cur for Currency, IU for Count, fraction for Ratio). Useful for comparisons and validation.
Quantity.of(50, "%").toBase().value; // 0.5.add(other) / .subtract(other)
Dimensionally-safe addition/subtraction. The result is expressed in the
unit of the receiver (this). Throws UnitMismatchError if dimensions
differ.
Quantity.of(1, "kg").add(Quantity.of(500, "g")); // Quantity(1.5, "kg")
Quantity.of(1, "kg").add(Quantity.of(1, "Mcal")); // throws UnitMismatchError.multiply(other) / .divide(other)
Performs real dimensional analysis: the resulting dimension is the product
(or quotient) of the two operands' dimensions, computed automatically. This
is the operation that replaces hand-written formulas like cp/100 * kg or
mg/kg * kg everywhere in a consuming application.
const cp = Quantity.of(12, "%");
const intake = Quantity.of(10, "kg/day");
intake.multiply(cp).to("g/day"); // Quantity(1200, "g/day")
const price = Quantity.of(0.35, "cur/kg");
intake.multiply(price).to("cur/day"); // Quantity(3.5, "cur/day").scale(factor)
Multiplies the numeric value by a plain number, keeping the same unit.
Use this only for dimensionless scaling factors (e.g. a safety margin), not
for anything that has its own unit — use .multiply() for that.
Quantity.of(10, "kg/day").scale(1.1); // Quantity(11, "kg/day") — a 10% buffer.equals(other, epsilon?)
Compares two quantities for equality within a tolerance, after normalizing
both to their base unit. Returns false (not an error) if dimensions
differ.
Quantity.of(1000, "g").equals(Quantity.of(1, "kg")); // true.hasSameDimension(other)
Checks dimension compatibility without throwing — the safe way to guard a conversion or comparison before doing it.
if (value.hasSameDimension(requirement)) { /* safe to compare */ }.toString()
Human-readable debug string, e.g. "12 % DM", "3.2 Mcal/kg".
Q() factory
Sugar for Quantity.of().
import { Q } from "@vetwo/units";
const intake = Q(10, "kg/day");parseUnit()
Parses a unit string into a resolved Unit object without creating a
Quantity. Mostly useful internally or when you need to inspect a unit's
dimension before deciding what to do with a value.
import { parseUnit } from "@vetwo/units";
const unit = parseUnit("mg/kg DM");
unit.dimension; // {} (dimensionless — mass/mass cancels)
unit.basis; // "DM"UnitRegistry
Holds the set of known atomic units. The package ships a
defaultUnitRegistry seeded with the built-in units; you can register more
atomic units for your own domain, or create an isolated registry instance
(useful in tests or multi-tenant setups).
class UnitRegistry {
constructor(seed?: readonly AtomicUnitDef[]);
registerAtomic(def: AtomicUnitDef): void;
hasAtomic(symbol: string): boolean;
getAtomic(symbol: string): AtomicUnitDef;
listAtomics(): readonly AtomicUnitDef[];
}
export const defaultUnitRegistry: UnitRegistry;import { defaultUnitRegistry } from "@vetwo/units";
defaultUnitRegistry.registerAtomic({
symbol: "mol",
dimension: { N: 1 }, // pick whichever base dimension fits your domain
toBaseFactor: 1,
label: "mole",
});
// "mol/L" now parses automatically — no other change required// Isolated registry for tests:
const testRegistry = new UnitRegistry();
Quantity.of(1, "kg", testRegistry);CalculationRuleRegistry
A generic, named registry for domain calculation rules layered on top
of Quantity math. The core package doesn't know what "molarity" or
"crude protein contribution" means — domain packages register named rules
here instead of hard-coding formulas inline.
class CalculationRuleRegistry {
register(key: string, rule: (...inputs: Quantity[]) => Quantity): void;
has(key: string): boolean;
resolve(key: string): (...inputs: Quantity[]) => Quantity;
run(key: string, ...inputs: Quantity[]): Quantity;
listKeys(): string[];
}
export const defaultCalculationRuleRegistry: CalculationRuleRegistry;import { defaultCalculationRuleRegistry, Quantity } from "@vetwo/units";
defaultCalculationRuleRegistry.register("chemistry.molarity", (moles, volume) =>
moles.divide(volume)
);
defaultCalculationRuleRegistry.run(
"chemistry.molarity",
Quantity.of(0.5, "mol"),
Quantity.of(2, "L")
); // 0.25 mol/LformatQuantity()
Renders a Quantity as a display string — intended for report/UI layers,
never for calculation.
function formatQuantity(q: Quantity, opts?: {
decimals?: number; // default 2
showBasis?: boolean; // default true
locale?: string; // uses Intl number formatting if provided
}): string;formatQuantity(Quantity.of(12.345, "Mcal/day"), { decimals: 1 }); // "12.3 Mcal/day"
formatQuantity(Quantity.of(15, "% DM")); // "15.00 % DM"
formatQuantity(Quantity.of(1234.5, "cur/day"), { locale: "en-US" }); // "1,234.5 cur/day"serializeQuantity() / deserializeQuantity()
Round-trip a Quantity through plain JSON — for database storage or API
payloads.
function serializeQuantity(q: Quantity): { value: number; unit: string };
function deserializeQuantity(data: { value: number; unit: string }, registry?: UnitRegistry): Quantity;const json = serializeQuantity(Quantity.of(12, "% DM"));
// { value: 12, unit: "% DM" }
const restored = deserializeQuantity(json);Type guards
function isQuantity(value: unknown): value is Quantity;
function isUnit(value: unknown): value is Unit;
function isRatioUnit(unit: Unit): boolean; // % / ppm / any dimensionless compound
function isSameDimension(a: DimensionVector, b: DimensionVector): boolean;
function isMoneyQuantity(q: Quantity): boolean; // pure Currency dimension onlyif (isQuantity(payload)) { /* safe to call .to(), .multiply(), etc. */ }Testing utilities
function assertQuantityClose(actual: Quantity, expected: Quantity, epsilon?: number, message?: string): void;
function approxEqual(a: number, b: number, epsilon?: number): boolean;import { assertQuantityClose, Quantity } from "@vetwo/units";
assertQuantityClose(result, Quantity.of(1200, "g/day"), 1e-6);Errors
All errors extend UnitEngineError, so you can catch broadly or narrowly.
class UnitEngineError extends Error {}
class UnitMismatchError extends UnitEngineError {} // add/subtract, different dimensions
class UnsupportedUnitError extends UnitEngineError {} // unknown unit symbol
class DimensionError extends UnitEngineError {} // wrong dimension for the operation
class ConversionError extends UnitEngineError {} // conversion failed (e.g. basis clash)
class ImpossibleConversionError extends UnitEngineError {} // no shared dimension, no bridging rule
class RuleNotFoundError extends UnitEngineError {} // unregistered CalculationRuleRegistry keytry {
Quantity.of(1, "kg").add(Quantity.of(1, "Mcal"));
} catch (e) {
if (e instanceof UnitMismatchError) {
// handle: "Unit mismatch: cannot combine "kg" with "Mcal" (different dimensions)."
}
}Extending the engine for a new domain
@vetwo/units was designed so a brand-new scientific/financial domain can
be added without ever touching this package's source:
- Register any new atomic units you need via
defaultUnitRegistry.registerAtomic(). - Register your domain's formulas via
defaultCalculationRuleRegistry.register(). - Wrap both in your own package (e.g.
@vetwo/nutrition-units, or a@you/chemistry-units) that exposes a friendly facade — the wayNutritionMathandCoefficientResolverdo in@vetwo/nutrition-units.
This package should never contain nutrition, chemistry, or finance-specific code — if you find yourself wanting to add that here, it belongs in a domain package instead.
Design principles
- No magic numbers outside one file. Every raw conversion constant lives in a single, documented internal file; nowhere else in this package (or any consuming application) should contain a bare conversion factor.
- Single Responsibility per module — parsing, registry, math, formatting, and serialization are each isolated.
- Open/Closed — new units and new domain rules are added by registering, never by editing internals.
- Zero domain knowledge — this package is safe to reuse in any scientific or financial context.
Requirements
- TypeScript ^5.x
- No runtime dependencies
License
MIT
