@penner/smart-primitive
v0.1.1
Published
Type-safe branded primitives with zero runtime overhead - prevent bugs by distinguishing different kinds of numbers, strings, and booleans
Maintainers
Readme
@penner/smart-primitive
Type-safe primitives for TypeScript. Catch unit mix-ups at compile time with zero runtime cost.
Installation
npm install @penner/smart-primitiveQuick Start
Import ready-to-use types, annotate your variables and parameters, and let TypeScript catch mistakes:
import { Pixels, Milliseconds, Degrees } from '@penner/smart-primitive';
function animate(
element: HTMLElement,
distance: Pixels,
duration: Milliseconds,
rotation: Degrees,
) {
// ...
}
const dist: Pixels = 100;
const time: Milliseconds = 500;
const angle: Degrees = 90;
animate(el, dist, time, angle); // ✅ correct
animate(el, time, dist, angle); // ❌ Error! Can't use Milliseconds where Pixels expectedPlain values are always assignable — no casting or wrapping required:
const width: Pixels = 300; // ✅ plain number works
const delay: Milliseconds = 1000; // ✅ plain number works
const opacity: Alpha = 0.8; // ✅ plain number worksAvailable Unit Types
| Category | Types |
| ----------- | -------------------------------------------------------------------------------------------------------------------- |
| Time | Seconds, Milliseconds, Minutes, Hours |
| Length | Pixels, Ems, Rems, Vw, Vh, Percent, Points, Inches, Centimeters, Millimeters, Meters |
| Angle | Degrees, Radians, Turns |
| Normalized | Normalized (0–1), SignedNormalized (−1–1), ClampedNormalized, Percentage (0–100), Alpha, Ratio, Factor |
| Temperature | Celsius, Fahrenheit |
| Color | ColorByte (0–255), RGBTuple, RGBATuple |
| Integer | Index |
| Animation | NormalizedTime (clamped 0–1), NormalizedProgress (can overshoot), PercentProgress |
All types are importable from the package root or from @penner/smart-primitive/units.
The Trait System
These types are built from a small set of composable traits — phantom interfaces combined via TypeScript intersection (&).
How Types Are Composed
A simple type like Pixels needs just a unit and kind:
// Pixels = number with unit 'px' and kind 'length'
type Pixels = WithUnit<'px'> & WithKind<'length'>;A richer type layers on more traits:
// Degrees = number with unit 'deg', range [0, 360), periodic wrapping, kind 'angle'
type Degrees = WithUnit<'deg'> &
WithRange<0, 360> &
WithPeriodic &
WithKind<'angle'>;Some types only need a range:
// Normalized = number in the range [0, 1], unclamped (can overshoot)
type Normalized = WithRange<0, 1>;
// ClampedNormalized = Normalized that's guaranteed within bounds
type ClampedNormalized = Normalized & WithClamped;Core Traits
| Trait | Purpose |
| --------------------- | ---------------------------------------------------------------------------- |
| WithUnit<U> | Labels the measurement unit ('px', 'ms', 'deg', …) |
| WithRange<Min, Max> | Documents the reference range (informational) |
| WithClamped | Marks the value as constrained to its range |
| WithPeriodic | Marks the value as wrapping at range boundaries |
| WithKind<K> | Groups related types under an archetype ('length', 'time', 'angle', …) |
| WithInteger | Marks the value as a whole number |
Defining Custom Types
Compose traits to define your own domain types:
import {
WithUnit,
WithRange,
WithClamped,
WithKind,
} from '@penner/smart-primitive';
// A simple branded unit
type Frames = WithUnit<'frames'> & WithKind<'time'>;
// A bounded value
type Volume = WithRange<0, 100> & WithUnit<'percent'> & WithClamped;
// An unbounded measurement
type Force = WithUnit<'N'> & WithKind<'force'>;Trait Extraction
Query type-level metadata from trait-based types:
import type {
UnitOf,
RangeOf,
MinOf,
MaxOf,
IsClamped,
IsPeriodic,
KindOf,
} from '@penner/smart-primitive';
type U = UnitOf<Degrees>; // 'deg'
type R = RangeOf<Degrees>; // readonly [0, 360]
type K = KindOf<Pixels>; // 'length'
type C = IsClamped<Alpha>; // false
type P = IsPeriodic<Degrees>; // trueTrait Modification
Transform types by adding, removing, or changing traits:
import type {
WithoutClamped,
ChangeUnits,
ChangeRange,
} from '@penner/smart-primitive';
type Unclamped = WithoutClamped<ClampedNormalized>; // remove clamping
type InInches = ChangeUnits<Pixels, 'in'>; // swap units
type WiderRange = ChangeRange<Normalized, -1, 2>; // change rangeRuntime Utilities
The clamp and wrap functions operate on trait-based types and refine the output type accordingly:
import { clamp, wrap } from '@penner/smart-primitive';
const v = clamp(1.5, 0, 1); // value: 1, type: WithRange<0, 1> & WithClamped
const w = wrap(450, 0, 360); // value: 90, type: WithRange<0, 360> & WithPeriodicConversions
Each unit module ships pre-built converter functions:
import { degreesToRadians, radiansToDegrees } from '@penner/smart-primitive';
import {
secondsToMilliseconds,
celsiusToFahrenheit,
} from '@penner/smart-primitive';
import {
normalizedToPercentage,
metersToCentimeters,
} from '@penner/smart-primitive';
const rad: Radians = degreesToRadians(90); // π/2
const ms: Milliseconds = secondsToMilliseconds(2); // 2000
const pct: Percentage = normalizedToPercentage(0.75); // 75For custom conversions, use the factory functions:
import {
createConverter,
createBiConverter,
createLinearConverter,
} from '@penner/smart-primitive';
// One-way converter
const framesToSeconds = createConverter<Frames, Seconds>(f => f / 60);
// Bidirectional converter
const pixelsRems = createBiConverter<Pixels, Rems>(
px => px / 16,
rem => rem * 16,
);
pixelsRems.to(32); // 2 Rems
pixelsRems.from(1.5); // 24 Pixels
// Linear ratio converter
const metersCm = createLinearConverter<Meters, Centimeters>(100);Advanced patterns like chain (compose converters) and ConverterRegistry (runtime lookup) are also available.
String Types
The library includes semantic string types via @penner/smart-primitive/strings, with template literal validation where possible:
import type {
CSSLength,
CSSCustomProperty,
UUID,
EmailAddress,
} from '@penner/smart-primitive/strings';
const width: CSSLength = '16px'; // template: `${number}${CSSLengthUnit}`
const prop: CSSCustomProperty = '--main-color'; // template: `--${string}`
const id: UUID = '550e8400-e29b-41d4-a716-446655440000';
const email: EmailAddress = '[email protected]';See the strings module for the full list of CSS, web, and data format types.
SmartPrimitive & SmartNumber
For quick, ad-hoc branded types that don't need trait composition, SmartNumber and SmartString are still available:
import { SmartNumber, SmartString } from '@penner/smart-primitive';
type Score = SmartNumber<'Score'>;
type SessionToken = SmartString<'SessionToken'>;
const points: Score = 42;
const token: SessionToken = 'abc123';Note: All pre-built unit types (
Pixels,Degrees,Milliseconds, etc.) are now defined using the trait system rather thanSmartNumber. Trait-based types carry richer metadata (units, ranges, kinds) and support extraction utilities likeUnitOfandRangeOf. For a discussion of the tradeoffs between the two approaches, see SmartPrimitive vs Traits analysis.
Feature Flag: Toggle Type Safety
Disable all smart typing across your project via module augmentation:
// In a .d.ts file in your project (e.g., smart-primitive.d.ts):
declare module '@penner/smart-primitive' {
interface SmartPrimitiveConfig {
usePlainPrimitives: true;
}
}When enabled, all smart types collapse to plain primitives (number, string, etc.) and all cross-type assignments are allowed. Useful for debugging type issues or gradual migration.
Utility Types
Unbrand<T>
Strip branding from types, recursing into object structures:
import type { Unbrand } from '@penner/smart-primitive';
type Config = { width: Pixels; duration: Milliseconds };
type Plain = Unbrand<Config>;
// { width: number; duration: number }UnbrandFn<F>
Strip branding from function parameter types:
import type { UnbrandFn } from '@penner/smart-primitive';
declare function move(distance: Pixels, duration: Milliseconds): void;
type PlainMove = UnbrandFn<typeof move>;
// (distance: number, duration: number) => voidTypeScript Compatibility
Requires TypeScript 4.5 or higher.
License
MIT
