unacy
v0.7.0
Published
Type-safe unit and format conversion library
Readme
@unacy/core
Type-safe unit and format conversion library with automatic multi-hop composition.
Features
- 🔒 Type-safe conversions - Compile-time checks prevent mixing incompatible units
- 🔄 Auto-composition - Automatically chains converters (A→B→C for A→C)
- 🚀 Zero runtime overhead - Phantom types have no performance cost
- 🎯 Shortest path - BFS finds optimal conversion routes
- 🛡️ Cycle detection - Prevents infinite conversion loops
- 📦 Tree-shakeable - Only bundle converters you use
- ✨ Fluent API - Clean, readable conversion syntax
- 🎯 Typed Metadata - Native support for
number,string,boolean, andbigintunits
Installation
pnpm add @unacy/coreQuick Start
import { createRegistry } from '@unacy/core';
import type { WithTypedUnits } from '@unacy/core';
// Define metadata for your units (name + type)
const CelsiusMetadata = {
name: 'Celsius' as const,
type: 'number' as const
};
const FahrenheitMetadata = {
name: 'Fahrenheit' as const,
type: 'number' as const
};
// Define your unit types with metadata
type Celsius = WithTypedUnits<typeof CelsiusMetadata>;
type Fahrenheit = WithTypedUnits<typeof FahrenheitMetadata>;
// Create a registry and register converters
const tempRegistry = createRegistry()
.register(CelsiusMetadata, FahrenheitMetadata, (c) => ((c * 9/5) + 32));
// Create branded values using callable accessors (NEW!)
const temp = tempRegistry.Celsius(25); // Returns Celsius type
// Convert with type safety - two ways:
// Method 1: unit accessor API
const fahrenheit1 = tempRegistry.Celsius.to.Fahrenheit(temp);
// Method 2: fluent callable accessor API
const fahrenheit2 = tempRegistry.Celsius.to.Fahrenheit(tempRegistry.Celsius(30));
console.log(fahrenheit1); // 77
console.log(fahrenheit2); // 86
// Old way still works (manual casting)
const tempOld: Celsius = 25 as Celsius;Usage Examples
Callable Unit Accessors
Unit accessors are now callable functions that create branded values:
// Create branded values without manual type casting
const temp = registry.Celsius(25); // Returns WithTypedUnits<typeof CelsiusMetadata>
const distance = registry.meters(100); // Returns WithTypedUnits<typeof MetersMetadata>
// Fluent workflow
const fahrenheit = registry.Celsius.to.Fahrenheit(registry.Celsius(20));
// Compare with old way (still works)
const tempOld: Celsius = 25 as Celsius;
// Benefits:
// - Cleaner syntax
// - Less verbose than manual casting
// - Type-safe by design
// - Works seamlessly with conversionsTyped Metadata
Define metadata with minimal required fields (name + type):
const CelsiusMetadata = {
name: 'Celsius' as const,
type: 'number' as const
};
const EtherMetadata = {
name: 'ether' as const,
type: 'bigint' as const
};
const FlagMetadata = {
name: 'enabled' as const,
type: 'boolean' as const
};Basic Unit Conversions
// Same registry as above
const distance: Meters = 10 as Meters;
// Access units directly via property syntax
const feet = distanceRegistry.meters.to.feet(distance);
console.log(feet); // 32.8084
// Or use callable accessors
const feet2 = distanceRegistry.meters.to.feet(distanceRegistry.meters(10));
// Works in both directions
const meters = distanceRegistry.feet.to.meters(32.8084 as Feet) satisfies Meters;
console.log(meters); // 10Bidirectional Converters
import { createRegistry } from '@unacy/core';
import type { WithTypedUnits } from '@unacy/core';
const MetersMetadata = {
name: 'meters' as const,
type: 'number' as const
};
const KilometersMetadata = {
name: 'kilometers' as const,
type: 'number' as const
};
type Meters = WithTypedUnits<typeof MetersMetadata>;
type Kilometers = WithTypedUnits<typeof KilometersMetadata>;
const registry = createRegistry()
.register(MetersMetadata, KilometersMetadata, {
to: (m: number) => (m / 1000),
from: (km: number) => (km * 1000)
});
// Both directions work automatically
const km = registry.convert(5000 as Meters, 'meters').to('kilometers'); // 5
const m = registry.convert(5 as Kilometers, 'kilometers').to('meters'); // 5000Multi-Hop Auto-Composition
The registry automatically composes converters via shortest path:
const MetersMetadata = { name: 'meters' as const, type: 'number' as const };
const KilometersMetadata = { name: 'kilometers' as const, type: 'number' as const };
const MilesMetadata = { name: 'miles' as const, type: 'number' as const };
type Meters = WithTypedUnits<typeof MetersMetadata>;
type Kilometers = WithTypedUnits<typeof KilometersMetadata>;
type Miles = WithTypedUnits<typeof MilesMetadata>;
const registry = createRegistry()
.register(MetersMetadata, KilometersMetadata, {
to: (m: number) => (m / 1000),
from: (km: number) => (km * 1000)
})
.register(KilometersMetadata, MilesMetadata, {
to: (km: number) => (km * 0.621371),
from: (mi: number) => (mi / 0.621371)
});
// No direct meters→miles converter registered!
// Registry auto-composes: meters → kilometers → miles
const meters: Meters = 5000 as Meters;
const miles = registry.convert(meters, 'meters').to('miles');
console.log(miles); // 3.106855Format-Tagged Values
import { type WithFormat, type FormatterParser, ParseError } from '@unacy/core';
import { z } from 'zod';
type ISO8601 = WithFormat<Date, 'ISO8601'>;
const iso8601: FormatterParser<ISO8601> = {
format: (date) => date.toISOString(),
parse: (input) => {
const schema = z.string().datetime();
try {
return new Date(schema.parse(input)) as ISO8601;
} catch {
throw new ParseError('ISO8601', input, 'Invalid date format');
}
}
};
// Format
const now: ISO8601 = new Date() as ISO8601;
const str = iso8601.format(now); // "2026-01-06T12:00:00.000Z"
// Parse
const date = iso8601.parse('2026-01-06T12:00:00.000Z');API Reference
Types
WithTypedUnits<M extends TypedMetadata<T>>
Brand a value with strongly-typed metadata for compile-time unit safety.
const CelsiusMetadata = { name: 'Celsius' as const, type: 'number' as const };
type Celsius = WithTypedUnits<typeof CelsiusMetadata>;
const temp: Celsius = tempRegistry.Celsius(25);WithUnits<T, U>
Legacy: Brand a value with a unit identifier for compile-time safety.
type Celsius = WithUnits<number, 'Celsius'>;
const temp: Celsius = 25 as Celsius;TypedMetadata<T>
Minimal metadata type with name and type information.
type NumericMetadata = TypedMetadata<number>;
// { name: string; type: 'number' }
type StringMetadata = TypedMetadata<string>;
// { name: string; type: 'string' }WithFormat<T, F>
Brand a value with a format identifier for serialization safety.
type ISO8601 = WithFormat<Date, 'ISO8601'>;
const date: ISO8601 = new Date() as ISO8601;Converter<TInput, TOutput>
Unidirectional converter function.
const c2f: Converter<Celsius, Fahrenheit> = (c) =>
((c * 9/5) + 32) as Fahrenheit;BidirectionalConverter<TInput, TOutput>
Pair of converters for two-way transformations.
const meterKm: BidirectionalConverter<Meters, Kilometers> = {
to: (m: number) => (m / 1000),
from: (km: number) => (km * 1000)
};Registry
createRegistry<Units>()
Create a new converter registry.
const registry = createRegistry<'A' | 'B' | 'C'>();register(from, to, converter)
Register a unidirectional converter.
registry.register(CelsiusMetadata, FahrenheitMetadata, celsiusToFahrenheit);register(from, to, converter) (bidirectional)
Register both directions at once.
registry.register(MetersMetadata, KilometersMetadata, meterKm);convert(value, fromUnit).to(toUnit)
Fluent API for type-safe conversions.
const result = registry.convert(value, 'Celsius').to('Fahrenheit');Errors
UnacyError- Base error classCycleError- Cycle detected in conversion graphMaxDepthError- Exceeded maximum conversion depth (5 hops)ConversionError- No conversion path foundParseError- Invalid input during parsing
Best Practices
- Define metadata as const at module boundaries for consistency
- Use bidirectional converters when both directions are needed
- Document precision loss in converters
- Cache registries for performance
- Use
WithTypedUnitsfor brand-new code; leverage type inference - Validate with Zod in parsers
Performance
- Direct conversions: O(1) lookup
- Multi-hop conversions: O(V+E) BFS with caching
- Type checking: <1s for typical graphs (<100 units)
- Zero runtime overhead for type brands
License
MIT
