Forge anything measurable. Not just physics. Three primitives (defineUnit, defineConversion, forge) work against any unit and any dimension you import.
Ships with kits across multiple domains; define your own for anything else (game state, finance, lab assays, inventory, factions). See this in action with the Settlers of Crouton demo: a custom dimension, three units, and a recipe that turns wheat + ore into cities. Each link below runs the kit live against the built package:
geometry: length, area, volume; metric and imperial; rectangle, circle, sphere, and cylinder derivations.data-storage: bytes (decimal and IEC binary), bits; covers GB-vs-GiB and Gbit-vs-MB.
import { forge } from 'unitforge';
import { meter, foot } from 'unitforge/kits/geometry';
forge(meter, foot)(5); // 16.4042import { forge } from 'unitforge';
import { gigabyte, gibibyte } from 'unitforge/kits/data-storage';
forge(gigabyte, gibibyte)(500); // 465.66; the 500 GB drive Windows reports as 465 GBThree functions, deliberately. Not three hundred kits.
- Not a million-function API. The whole library is
defineUnit,defineConversion,forge. If you can read those three signatures you can read all of unitforge. - Not a units database. Kits ship a curated set; you add what you need. Same shape userland does, no plugin protocol, no registry.
- Not a physics engine. No vectors, integrators, or dimensional algebra solver. unitforge converts values; it does not simulate.
- Not a currency-rate fetcher.
usd → euris a value you define against a rate you provide, not a live feed. - Not a CLI. Library only.
convert-units is the incumbent (~185k weekly downloads); same problem space, different philosophy. This table compares against the version npm install convert-units actually installs today (2.3.4, published 2018-01-12, CJS-only). Where the in-development 3.0.0-beta changes a row, it's flagged.
convert-units 2.3.4 |
convert-units 3.0.0-beta |
unitforge | |
|---|---|---|---|
| Module format | CJS | ESM + CJS + UMD | ESM only |
| Bundled TypeScript types | ❌ (via @types/convert-units) |
✅ | ✅ |
| Custom measures / dimensions | ✅ customMeasure |
✅ customMeasure |
✅ defineUnit |
| Cross-dimensional conversions (wheat + ore → cities) | ❌ | ❌ | ✅ defineConversion |
| Dimension mismatch caught at | runtime | runtime | compile time (NoInfer on the to side) |
| Tree-shaking model | barrel; pass measures to configureMeasurements |
same | per-export subpath (unitforge/kits/<name>) |
The differentiators that matter for the "not just physics" thesis are cross-dimensional recipes (the entire Crouton story is one of these) and compile-time dimension safety. Module format and bundled types are parity moves the 3.x beta is making; once 3.x ships as latest, those rows stop being differentiators.
Atomic by design: your import graph is the runtime graph. Every unit and every conversion is an independent named export, annotated /*#__PURE__*/; each spec inlines its math, with no global registry and no lookup table to drag in. Fluent APIs like convert(5).from('m').to('ft') can't tree-shake to zero; the chain dispatches against a registry, and the registry stays in the bundle.
Production bundles pay only for what you actually import. Measured with esbuild --bundle --minify --tree-shaking=true:
| Import | min | gzip |
|---|---|---|
import { meter } from 'unitforge/kits/geometry' |
347 B | 278 B |
import { forge } + meter, centimeter (within-dim) |
3.9 kB | 1.7 kB |
import { forge } + cross-dim conversion (forge + 3 kit values) |
4.2 kB | 1.9 kB |
import * as g from 'unitforge/kits/geometry' + everything from main barrel |
7.4 kB | 2.7 kB |
import { VERSION } from 'unitforge/version' (opt-in, inlines package.json) |
2.2 kB | 1.0 kB |
Tarball: npm pack produces ≈ 52 kB packed / 230 kB unpacked (64 files; dist/ plus LICENSE, NOTICE.md, README.md, package.json).
bun add unitforge
pnpm add unitforge
yarn add unitforge
npm install unitforgeRequires Node 22+, ESM-only ("type": "module"), TypeScript moduleResolution: "node16" | "nodenext" | "bundler". No CJS build; no peer dependencies.
Three functions; three steps. The library's own kits use the same shape userland does.
defineUnitdeclares each unit value in a dimension you invent.defineConversiondeclares the recipe (within a dimension or across dimensions).forgereturns the typed converter.
import { defineUnit, defineConversion, forge } from 'unitforge';
const COUNT = 'count' as const;
const wheat = defineUnit({ id: 'wheat', label: 'Wheat', symbol: '🌾', dimension: COUNT, toBase: (v) => v, fromBase: (b) => b, base: true });
const ore = defineUnit({ id: 'ore', label: 'Ore', symbol: '🪨', dimension: COUNT, toBase: (v) => v, fromBase: (b) => b });
const city = defineUnit({ id: 'city', label: 'City', symbol: '🏰', dimension: COUNT, toBase: (v) => v, fromBase: (b) => b });
const buildCities = defineConversion({
inputs: { wheat: COUNT, ore: COUNT },
output: COUNT,
compute: ({ wheat, ore }) => Math.floor(Math.min(wheat / 2, ore / 3)),
});
const cities = forge({ wheat, ore }, city, { via: buildCities });
cities({ wheat: 6, ore: 9 }); // 3Drag the sliders and watch cities accumulate: Settlers of Crouton demo. Same code, live.
For multiplicative units (handspans, pints, miles), the exported linear(scale) helper builds the { toBase, fromBase } pair so you can compose alongside imports from a kit:
import { defineUnit, forge, linear } from 'unitforge';
import { LENGTH } from 'unitforge/dimensions';
import { foot } from 'unitforge/kits/geometry';
const handspan = defineUnit({ id: 'handspan', label: 'Handspan', symbol: 'hsp', dimension: LENGTH, ...linear(0.235) });
forge(handspan, foot)(4); // 3.084Inside kit-shipped unit definitions, inline the
toBase/fromBaseclosures rather than spreadinglinear(scale); the spread defeats per-export tree-shaking. The helper is for ad-hoc userland use where bundle size does not matter.
Three primitives. One consumer (forge); two factories (defineUnit, defineConversion). Full type signatures live in dist/index.d.ts; the llms.txt walks an agent through every overload.
Returns a converter function. Within-dimension forges take a scalar and return a scalar; cross-dimensional forges take an object input and require via: in the config. NoInfer<D> on the to side makes wrong-dimension calls (forge(meter, squareMeter)) a compile error.
Cross-dim pipeline: defensive copy of input, cache check (if memoize is on), run every validator and aggregate failures, throw ValidationError if any failed, normalize inputs to base units, run compute, denormalize outputs, write to cache, return.
Config options (same name = same effect across overloads):
| Option | Type | Effect |
|---|---|---|
via |
Conversion<I, O, T> |
Required for cross-dim. Carries the input shape, validator map, and compute. |
validate |
ValidatorMap<I, T> |
Call-site validators, additive on top of the conversion's own. |
precision |
number (non-negative integer) |
Rounds output AND cache key to this many decimal places. |
memoize |
number (0 to MEMO_CAP_MAX = 1_048_576) |
FIFO bounded cache. 0 or absent = off. DEFAULT_MEMO_CAP = 1024. |
A unit value: name, dimension, toBase/fromBase functions, optional base: true for the canonical base of a dimension. See Build your own for the call shape.
A conversion value: input shape (field name to dimension), output (single dimension or a record of dimensions), optional validators, a compute function written in base units. The library normalizes inputs from whatever unit the call site uses before invoking compute, then denormalizes the result.
Validators may return true/undefined to pass, a string to reject with that message, or throw (the original error is preserved on failure.cause). All validators run on every call; failures aggregate into a single ValidationError.
Re-exported from the root barrel: Unit, Conversion, Dimension, ForgeInput, ForgeOutput, UnitMap, ValidatorMap, ValidationFailure. Dimension uses the (string & {}) brand so user-defined dimensions ('gold' as const) are accepted without collapsing the union, while built-ins still surface in autocomplete. See llms.txt for the full type surface.
bun install && bun run checkSee CONTRIBUTING.md for the full per-task command table (test, typecheck, lint, build, packaging, knip).
- Bugs and feature requests: GitHub issues
- Security: private vulnerability reporting; see SECURITY.md
- Code of conduct: CODE_OF_CONDUCT.md
Adding a kit, adding a section to an existing kit, or adding a dimension: see EXTENDING.md. It documents the lib-side ritual, the demo-side three-file registration, and the tree-shake foot-gun on defineUnit spec literals.
Read llms.txt first. It is the canonical orientation document for this package, written for LLM ingestion: architecture, API surface, subpath exports, tree-shake implementation, and key files. The README is for humans evaluating the library; llms.txt is the same scope in a denser, more parseable shape.
MIT © the-simian. See LICENSE and NOTICE.md.
Crafted with care by Simiancraft.
