Skip to content

ADR-009: Branded Types for Dates, IDs, and Currencies

Date: 2026-04-06 Authors: Jean-Francois Meyers Scope: All @granit/* packages

All @granit/* packages expose API response types that include date fields, entity identifiers, and currency codes. Without further qualification, these are all typed as string:

interface BackgroundJobStatus {
readonly jobName: string;
readonly nextExecutionAt: string | null; // date or arbitrary string?
}
function getInvoice(tenantId: string, invoiceId: string): Promise<Invoice> { ... }
// Nothing prevents: getInvoice(invoiceId, tenantId) — compiles, fails at runtime

This offers no compile-time distinction between a date string, a UUID, a name, and a currency code. The result is silent bugs that only surface at runtime.

Introduce branded types in the @granit/types package:

TypeBrandUsage
ISODateString__brand: 'ISODateString'All date fields across API types
EntityId<Brand>__entity: BrandAll entity identifier fields
UserId, TenantId, CorrelationIdPre-defined EntityId aliasesCross-cutting IDs
CurrencyCodeUnion type (not branded)ISO 4217 currency codes
// @granit/types
export type ISODateString = string & { readonly __brand: 'ISODateString' };
export type EntityId<Brand extends string> = string & { readonly __entity: Brand };
export type UserId = EntityId<'User'>;
export type TenantId = EntityId<'Tenant'>;
export type CurrencyCode = 'EUR' | 'USD' | 'GBP' | ...; // union, not branded

Constructors cast plain strings to their branded type with no runtime overhead:

export function toISODateString(value: string): ISODateString {
return value as ISODateString;
}
export function toEntityId<Brand extends string>(value: string): EntityId<Brand> {
return value as EntityId<Brand>;
}

Replace string with ISODateString for all date fields across package types:

// Before
interface BackgroundJobStatus {
readonly nextExecutionAt: string | null;
readonly lastExecutionAt: string | null;
}
// After
import type { ISODateString } from '@granit/types';
interface BackgroundJobStatus {
readonly nextExecutionAt: ISODateString | null;
readonly lastExecutionAt: ISODateString | null;
}

When constructing a date from new Date() or user input, wrap with toISODateString():

import { toISODateString } from '@granit/types';
const now = toISODateString(new Date().toISOString());
const createdAt = toISODateString(apiResponse.createdAt);

ISODateString extends string, so it is assignable anywhere a string is expected (template literals, comparisons, JSON.stringify). The brand only prevents accidental assignment of an arbitrary string where ISODateString is required.

Per-entity IDs are declared by each domain package using EntityId<Brand>:

// @granit/invoicing
import type { EntityId } from '@granit/types';
export type InvoiceId = EntityId<'Invoice'>;
// @granit/subscriptions
export type SubscriptionId = EntityId<'Subscription'>;

Parameter inversion becomes a compile error:

// Compile error — InvoiceId is not assignable to SubscriptionId
api.cancelSubscription(invoiceId);

Cross-cutting IDs (UserId, TenantId, CorrelationId) are declared once in @granit/types and re-used across packages.

Currency codes are represented as a union type, not a branded type. A union provides auto-completion with exact valid values and prevents typos without requiring a cast constructor:

const currency: CurrencyCode = 'EUR'; // ✓
const bad: CurrencyCode = 'XX'; // ✗ compile error

A branded string would allow any string value at the call site (after a cast), defeating the purpose.

Option 1: Branded types in @granit/types (selected)

Section titled “Option 1: Branded types in @granit/types (selected)”
  • Advantage: zero runtime overhead, single source of truth, cross-package reuse, brands are erased at compile time
  • Disadvantage: requires toISODateString() / toEntityId() cast constructors at system boundaries (user input, API deserialization)
  • Advantage: validates the actual string format at runtime (e.g. regex for ISO 8601)
  • Disadvantage: runtime cost, requires schema definitions per type, overkill for values that arrive from trusted API responses
  • Advantage: zero migration effort
  • Disadvantage: no compile-time distinction between semantically different string values; parameter inversion bugs are silent

Option 4: Separate @granit/branded-types package

Section titled “Option 4: Separate @granit/branded-types package”
  • Advantage: isolated package
  • Disadvantage: adds a dependency hop for every consumer; @granit/types already exists as the home for cross-cutting type utilities
CriterionBranded typesZod schemasPlain string
Runtime overheadNonePer-parseNone
Compile-time safetyFullNoneNone
IDE auto-completePartial
Migration effortLow (cast at boundaries)HighNone
Format validationNoneFullNone

Branded types give maximum compile-time safety with zero runtime cost. Format validation (e.g. checking that a string is actually an ISO 8601 date) is the responsibility of the API layer, not the type system.

  • Parameter inversion on entity IDs is a compile error, not a runtime bug
  • Date fields are self-documenting: nextExecutionAt: ISODateString vs nextExecutionAt: string
  • ISODateString extends string — no breaking change for existing consumers that pass dates as plain strings to template literals, comparisons, etc.
  • CurrencyCode union provides auto-completion and prevents invalid codes
  • Cast constructors (toISODateString, toEntityId) are required at system boundaries (mock data, test fixtures, API deserialization)
  • TypeScript’s noUncheckedIndexedAccess interacts with branded types in array mutations — use find() + direct property mutation instead of array[idx] = { ...spread }

This decision should be re-evaluated if:

  • TypeScript introduces a native nominal keyword that makes explicit casts unnecessary
  • A Zod-based validation layer is introduced at the API response level, making branded constructors redundant