Transform recipes
Working recipes for everyday transform problems. Each recipe is a Transform.create (or Transform.chain) call, registered with the rest of the bookstore domain, that round-trips through jt.instantiate and jt.encode.
All recipes use the bookstore domain. For the underlying APIs see Transform.create and jt.encode, Transform.chain, and Transform.brand.
Date and time
ISO 8601 date-time string to Date
Wire format: '2026-01-15T10:30:00Z'. Decoded type: Date.
/**
* Transforms recipes — Example 1: ISO 8601 decoder/encoder round-trip
*
* Defines a date-string ↔ ISO 8601 canonical transform. The canonical schema is
* a string in `format: 'date-time'`, matching the bookstore's `Iso8601Schema` but
* with a unique `$id` so this transform doesn't interfere with other examples.
*
* The wire value is `aboxFixtures.order.placedAt`: the moment Bastian
* Balthazar Bux placed the order for the 1979 Thienemann first edition.
* Decode normalizes to canonical ISO 8601 string; encode reverses.
*/
import {
Transform
} from '../../../src/index.js';
import {
aboxFixtures, createBookstoreDocRegistry
} from '../bookstore/index.js';
// createBookstoreDocRegistry seeds a permissive copy of the bookstore — docs examples extend
// it with ad-hoc demo schemas; strict-graph checking is intentionally off here.
const jt = createBookstoreDocRegistry();
const PlacedAtTransform = Transform.create(
{
'$id': 'https://bookstore.example/PlacedAt',
'format': 'date-time',
'type': 'string'
} as const,
{
'decode': (wire: string) => {
// Decode normalizes the wire value to canonical ISO 8601 string.
return new Date(wire).toISOString();
},
'encode': (isoString: string) => {
// Encode returns the wire value (ISO string).
return isoString;
}
}
);
jt.set(PlacedAtTransform);
const wire: string = aboxFixtures.order.placedAt;
const decoded = jt.instantiate(PlacedAtTransform, wire);
// Canonical is an ISO 8601 string.
console.assert(typeof decoded === 'string');
console.assert(decoded === new Date(aboxFixtures.order.placedAt).toISOString());
// Check year and month by parsing the canonical string.
const date = new Date(decoded);
console.assert(date.getUTCFullYear() === 2026);
// April is month 3 (0-indexed)
console.assert(date.getUTCMonth() === 3);
// ISO string form of the canonical value
console.log('decoded canonical (ISO):', decoded);
// 2026 3 — extracted from canonical string
console.log('year / month:', date.getUTCFullYear(), date.getUTCMonth());
const reEncoded = jt.encode(PlacedAtTransform, decoded);
console.assert(typeof reEncoded === 'string');
// Canonical round-trip: encode(decode(x)) reproduces the normalized canonical form.
console.assert(reEncoded === decoded);
// Semantic round-trip: the time instant is preserved even if milliseconds differ.
console.assert(new Date(reEncoded).getTime() === date.getTime());
// Note: wire ≠ reEncoded due to millisecond normalization in canonical form
console.log('round-trip lossless (semantic):', new Date(reEncoded).getTime() === new Date(wire).getTime());
Symmetric and lossless: encode(decode(x)) === x for any RFC 3339 string.
Date-only string to Date at UTC midnight
Wire format: '2026-01-15'. The bare date format does not carry a time zone, so the decoder pins it to UTC midnight; the encoder strips the time component on the way out.
/**
* Date-only transform — `YYYY-MM-DD` string ↔ canonical date-only string
*
* Wire format `'YYYY-MM-DD'` (canonical `PublicationDateSchema` shape).
* Decoder normalizes to a canonical ISO 8601 date-only string (YYYY-MM-DD).
* Defines a schema with unique `$id` so it doesn't interfere with other examples.
*/
import {
Transform
} from '../../../src/index.js';
import {
aboxFixtures, createBookstoreDocRegistry
} from '../bookstore/index.js';
// createBookstoreDocRegistry seeds a permissive copy of the bookstore — docs examples extend
// it with ad-hoc demo schemas; strict-graph checking is intentionally off here.
const jt = createBookstoreDocRegistry();
const PublishedAtTransform = Transform.create(
{
'$id': 'https://bookstore.example/PublishedAt',
'pattern': '^\\d{4}-\\d{2}-\\d{2}$',
'type': 'string'
} as const,
{
'decode': (wire: string) => {
// Decode normalizes to canonical date-only string (YYYY-MM-DD).
const date = new Date(`${wire}T00:00:00Z`);
return date.toISOString().slice(0, 10);
},
'encode': (dateOnlyString: string) => {
// Encode returns the wire value (already in YYYY-MM-DD format).
return dateOnlyString;
}
}
);
jt.set(PublishedAtTransform);
const wire = aboxFixtures.rareBook.publishedOn;
const decoded = jt.instantiate(PublishedAtTransform, wire);
// Canonical is a date-only string.
console.assert(typeof decoded === 'string');
console.assert(decoded === wire);
// Check year by parsing the canonical string.
const date = new Date(`${decoded}T00:00:00Z`);
console.assert(date.getUTCFullYear() === 1979);
// '1979-09-01'
console.log('wire:', wire);
// 1979 — from canonical date-only string
console.log('decoded UTC year:', date.getUTCFullYear());
const reEncoded = jt.encode(PublishedAtTransform, decoded);
console.assert(reEncoded === wire);
// '1979-09-01' — round-trip
console.log('round-trip:', reEncoded);
Unix epoch milliseconds to Date
Wire format: integer milliseconds since the epoch.
/**
* Transforms recipes — Unix epoch milliseconds ↔ canonical ISO 8601 string
*
* Wire format: integer milliseconds since the epoch. Canonical: ISO 8601 string.
* The wire value is Bastian Balthazar Bux's order timestamp recast as
* epoch ms — the same scenario as `03-transforms-recipes.ts`, expressed
* in a different wire encoding.
*/
import { Transform } from '../../../src/index.js';
import {
aboxFixtures,
createBookstoreDocRegistry
} from '../bookstore/index.js';
// createBookstoreDocRegistry seeds a permissive copy of the bookstore — docs examples extend
// it with ad-hoc demo schemas; strict-graph checking is intentionally off here.
const jt = createBookstoreDocRegistry();
const TimestampTransform = Transform.create(
{
'$id': 'https://bookstore.example/Timestamp',
'format': 'date-time',
'type': 'string'
} as const,
{
'decode': (wire: number) => {
// Decode epoch ms (wire) to canonical ISO 8601 string.
return new Date(wire).toISOString();
},
'encode': (isoString: string) => {
// Encode canonical ISO string back to epoch ms (wire).
return new Date(isoString).getTime();
}
}
);
jt.set(TimestampTransform);
const wireMs = new Date(aboxFixtures.order.placedAt).getTime();
const decoded = jt.instantiate(TimestampTransform, wireMs);
// Canonical is an ISO 8601 string.
console.assert(typeof decoded === 'string');
const date = new Date(decoded);
console.assert(date.getUTCFullYear() === 2026);
// epoch ms for the order timestamp
console.log('wire ms:', wireMs);
// same instant as placedAt, in canonical ISO form
console.log('decoded ISO:', decoded);
const reEncoded = jt.encode(TimestampTransform, decoded);
console.assert(reEncoded === wireMs);
// true — lossless
console.log('round-trip equal:', reEncoded === wireMs);
For seconds-since-epoch swap * 1000 and / 1000.
Temporal API plain date
If your runtime ships Temporal, prefer Temporal.PlainDate over Date for calendar values - it has no time zone and no time component, so it round-trips cleanly without the UTC-midnight workaround. The runnable example below uses a hand-rolled PlainDate analogue because the Temporal global is not yet a stable Node.js builtin; swap the class for Temporal.PlainDate once your runtime ships it.
/**
* Transforms recipes — calendar date primitive (PlainDate analogue)
*
* Wire format: `'YYYY-MM-DD'`. Canonical: date-only string with parsed
* components recorded for later use. Demonstrates a transform that
* decodes to a canonical object shape (JSON-expressible).
*
* The canonical form is a plain object with year, month, day fields,
* suitable for JSON serialization and further processing. This mirrors
* the structure Temporal.PlainDate provides, but expressed as a
* canonical JSON object instead of a class instance.
*
* Registered on a sibling of the canonical `PublicationDateSchema`
* (which itself decodes to date-only string in `04-transforms-date-only.ts`).
* Bastian's rare 1979 first edition publication date is the wire
* value.
*/
import {
Transform
} from '../../../src/index.js';
import {
aboxFixtures, createBookstoreDocRegistry
} from '../bookstore/index.js';
// createBookstoreDocRegistry seeds a permissive copy of the bookstore — docs examples extend
// it with ad-hoc demo schemas; strict-graph checking is intentionally off here.
const jt = createBookstoreDocRegistry();
interface PlainDateCanonical {
readonly 'day': number;
readonly 'month': number;
readonly 'year': number;
}
function parseDate(wire: string): PlainDateCanonical {
const [
year,
month,
day
] = wire.split('-').map((part) => {
return Number.parseInt(part, 10);
}) as [number | undefined, number | undefined, number | undefined];
if (year === undefined || month === undefined || day === undefined) {
throw new TypeError(`parseDate: invalid wire '${wire}'`);
}
return {
day,
month,
year
};
}
function formatDate(date: PlainDateCanonical): string {
const month = String(date.month).padStart(2, '0');
const day = String(date.day).padStart(2, '0');
return `${String(date.year)}-${month}-${day}`;
}
const ReleaseDateTransform = Transform.create(
{
'$id': 'https://bookstore.example/ReleaseDate',
'properties': {
'day': { 'type': 'number' },
'month': { 'type': 'number' },
'year': { 'type': 'number' }
},
'required': [
'year',
'month',
'day'
],
'type': 'object'
} as const,
{
'decode': (wire: string) => {
// Decode to canonical plain object with year, month, day.
return parseDate(wire);
},
'encode': (date: PlainDateCanonical) => {
// Encode back to wire string format.
return formatDate(date);
}
}
);
jt.set(ReleaseDateTransform);
const wire = aboxFixtures.rareBook.publishedOn;
const decoded = jt.instantiate(ReleaseDateTransform, wire);
// Canonical is a plain object with year, month, day.
console.assert(typeof decoded === 'object');
const plainDate = decoded;
console.assert(plainDate.year === 1979);
console.assert(plainDate.month === 9);
console.assert(plainDate.day === 1);
// '1979-09-01'
console.log('wire:', wire);
// 1979 9 1 — no time zone
console.log('PlainDate:', plainDate.year, plainDate.month, plainDate.day);
const reEncoded = jt.encode(ReleaseDateTransform, plainDate);
console.assert(reEncoded === wire);
// '1979-09-01' — round-trip
console.log('re-encoded:', reEncoded);
Money and numerics
Cents (integer) to a decimal type
Storing money as integer cents avoids floating-point error. Decode to a Decimal from your library of choice (e.g. decimal.js), encode back to cents. The runnable example below uses a bigint-backed BigCents wrapper so it has no external dependency; swap the wrapper for Decimal (or your own arbitrary-precision type) when integrating.
/**
* Transforms recipes — integer cents ↔ decimal major units (number)
*
* Storing money as integer cents avoids floating-point error. The
* canonical form is a decimal number (major.minor units), computed
* safely from the wire cents via division, avoiding floating-point
* precision loss for typical bookstore amounts.
*/
import {
Transform
} from '../../../src/index.js';
import {
createBookstoreDocRegistry
} from '../bookstore/index.js';
// createBookstoreDocRegistry seeds a permissive copy of the bookstore — docs examples extend
// it with ad-hoc demo schemas; strict-graph checking is intentionally off here.
const jt = createBookstoreDocRegistry();
const BigCentsTransform = Transform.create(
{
'$id': 'https://bookstore.example/BigCents',
'minimum': 0,
'type': 'number'
} as const,
{
'decode': (cents: number) => {
// Decode wire cents to canonical decimal (major.minor).
return cents / 100;
},
'encode': (majorUnits: number) => {
// Encode canonical decimal back to integer cents.
return Math.round(majorUnits * 100);
}
}
);
jt.set(BigCentsTransform);
const wireCents = 85_000;
const decoded = jt.instantiate(BigCentsTransform, wireCents);
// Canonical is a decimal number.
console.assert(typeof decoded === 'number');
console.assert(decoded === 850);
// 85000
console.log('wire cents:', wireCents);
// 850 — canonical decimal amount
console.log('decoded major units:', decoded);
const reEncoded = jt.encode(BigCentsTransform, decoded);
console.assert(reEncoded === wireCents);
// 85000 — round-trip
console.log('re-encoded cents:', reEncoded);
If you prefer the project's built-in Money composite, keep cents as the wire format and use Money for the decoded slot.
Formatted string to float (multi-step chain)
Wire format: '$1,234.56'. Two decoders run left to right; encoders run right to left.
/**
* Transforms recipes — formatted price string ↔ number via Transform.chain
*
* Wire format: `'$1,234.56'`. Two stages run left-to-right on decode;
* encoders run right-to-left. Registered against a new
* `FormattedPrice` primitive sibling of the bookstore `AmountSchema`
* so the canonical amount primitive stays as a bare number.
*
* The wire value is a formatted print of the rare-book price for the
* 1979 Thienemann first edition of Michael Ende's Die unendliche
* Geschichte (EUR 850).
*/
import { Transform } from '../../../src/index.js';
import {
aboxFixtures,
createBookstoreDocRegistry
} from '../bookstore/index.js';
// createBookstoreDocRegistry seeds a permissive copy of the bookstore — docs examples extend
// it with ad-hoc demo schemas; strict-graph checking is intentionally off here.
const jt = createBookstoreDocRegistry();
const FormattedPriceTransform = Transform.chain(
{
'$id': 'https://bookstore.example/FormattedPrice',
'type': 'number'
} as const,
[
{
'decode': (raw: string) => {
return raw.replaceAll(/[$,]/gu, '');
},
'encode': (clean: string) => {
return `$${clean}`;
}
},
{
'decode': (clean: string) => {
return Number.parseFloat(clean);
},
'encode': (value: number) => {
return value.toFixed(2);
}
}
] as const
);
jt.set(FormattedPriceTransform);
const wireAmount = aboxFixtures.rareBook.price.amount;
const wire = `$${wireAmount.toFixed(2)}`;
const parsed = jt.instantiate(FormattedPriceTransform, wire);
console.assert(parsed === wireAmount);
// e.g. '$850.00'
console.log('wire string:', wire);
// 850 — stage 1 strips '$', stage 2 parses
console.log('parsed number:', parsed);
const reEncoded = jt.encode(FormattedPriceTransform, wireAmount);
// Encoder collapses thousands separators by design; round-trip is
// numerically faithful, formatting-wise lossy (no thousand-separator
// re-insertion on the way out).
console.assert(reEncoded === `$${wireAmount.toFixed(2)}`);
// '$850.00' — encoders run right-to-left
console.log('re-encoded:', reEncoded);
jt.instantiate(..., '$1,234.56') yields 1234.56; jt.encode(..., 1234.56) yields '$1234.56'. (Note the encoder does not re-insert thousands separators - that is a one-way concern; add a third stage if your wire format requires it on the way out.)
BigInt-shaped identifiers
JSON cannot natively represent BigInt. Stringify on the wire; parse on decode.
/**
* Transforms recipes — numeric identifier string ↔ canonical string
*
* The wire is a numeric string (e.g., ISBN-13). The canonical form
* is also a string; decode validates the format and normalizes it
* to canonical string form. This avoids BigInt entirely, keeping
* the canonical value JSON-expressible.
*
* Registered as a sibling string primitive so a wire numeric string
* can be normalized and validated without touching the canonical
* `OrderIdSchema` (UUID).
*
* The wire is the numeric form of a hypothetical 64-bit catalogue id
* for the 1979 Thienemann first edition of Die unendliche Geschichte.
*/
import { Transform } from '../../../src/index.js';
import { createBookstoreDocRegistry } from '../bookstore/index.js';
// createBookstoreDocRegistry seeds a permissive copy of the bookstore — docs examples extend
// it with ad-hoc demo schemas; strict-graph checking is intentionally off here.
const jt = createBookstoreDocRegistry();
const BigIdTransform = Transform.create(
{
'$id': 'https://bookstore.example/BigId',
'pattern': '^\\d+$',
'type': 'string'
} as const,
{
'decode': (wire: string) => {
// Decode: validate and normalize numeric string to canonical form.
// Verify it's a valid numeric string by attempting to parse as BigInt.
BigInt(wire);
return wire;
},
'encode': (value: string) => {
// Encode: return the canonical string.
return value;
}
}
);
jt.set(BigIdTransform);
const wire = '9783522128001';
const decoded = jt.instantiate(BigIdTransform, wire);
// Canonical is a string.
console.assert(typeof decoded === 'string');
console.assert(decoded === wire);
// '9783522128001' — ISBN-13 as string
console.log('wire string:', wire);
// '9783522128001' — canonical string
console.log('decoded id string:', decoded);
const reEncoded = jt.encode(BigIdTransform, decoded);
console.assert(reEncoded === wire);
// '9783522128001' — round-trip
console.log('re-encoded:', reEncoded);
Identifiers and strings
Email normalization (lowercase, trim)
Validation alone does not normalize. Use a transform when you want the canonical form on every read.
/**
* Transforms recipes — email normalization (lowercase + trim)
*
* Validation alone does not normalize. The decoder canonicalizes the
* incoming wire string; the encoder is the identity so the wire form
* preserves whatever the decoder produced. Registered as a sibling
* email primitive against `bookstoreEntities` so the canonical
* `EmailSchema` stays free of leaked transforms.
*
* The fixture is Bastian Balthazar Bux's email, fed in with stray
* whitespace and mixed case to demonstrate the normalization.
*/
import { Transform } from '../../../src/index.js';
import {
aboxFixtures,
createBookstoreDocRegistry
} from '../bookstore/index.js';
// createBookstoreDocRegistry seeds a permissive copy of the bookstore — docs examples extend
// it with ad-hoc demo schemas; strict-graph checking is intentionally off here.
const jt = createBookstoreDocRegistry();
// Wire format does NOT include the 'email' format check — the whole
// point of the transform is to normalize whitespace and casing before
// the value reaches the canonical EmailSchema. Downstream consumers
// can ref the canonical schema once the decoded value is in hand.
const NormalizedEmailSchema = {
'$id': 'https://bookstore.example/NormalizedEmail',
'type': 'string'
} as const;
jt.set(NormalizedEmailSchema);
const NormalizedEmailTransform = Transform.create<typeof NormalizedEmailSchema, string>(NormalizedEmailSchema, {
'decode': (raw) => {
return raw.trim().toLowerCase();
},
'encode': (clean) => {
return clean;
}
});
const wire = ` ${aboxFixtures.customer.email.toUpperCase()} `;
const decoded = jt.instantiate(NormalizedEmailTransform, wire);
console.assert(decoded === aboxFixtures.customer.email);
// uppercased with surrounding spaces
console.log('wire (dirty):', wire.trim());
// lowercase, trimmed
console.log('decoded (canonical):', decoded);
const reEncoded = jt.encode(NormalizedEmailTransform, aboxFixtures.customer.email);
console.assert(reEncoded === aboxFixtures.customer.email);
// true — encoder preserves canonical form
console.log('encode is identity:', reEncoded === decoded);
The encoder is the identity, so the wire form preserves whatever the decoder produced. If you need to track the original, register a sibling property.
URL string to URL object
/**
* Transforms recipes — URL string ↔ canonical normalized URL string
*
* Wire format: `string` with `format: 'uri'`. Canonical: normalized
* URL string. Decode validates and normalizes the URL by parsing and
* re-stringifying it, ensuring consistent formatting and validity.
* Registered as a new string primitive against
* `bookstoreEntities` so callers can validate and normalize catalogue
* links.
*
* The wire is the catalogue page for the 1979 Thienemann first
* edition of Die unendliche Geschichte that Bastian ordered.
*/
import { Transform } from '../../../src/index.js';
import {
aboxFixtures,
createBookstoreDocRegistry
} from '../bookstore/index.js';
// createBookstoreDocRegistry seeds a permissive copy of the bookstore — docs examples extend
// it with ad-hoc demo schemas; strict-graph checking is intentionally off here.
const jt = createBookstoreDocRegistry();
const HrefTransform = Transform.create(
{
'$id': 'https://bookstore.example/Href',
'format': 'uri',
'type': 'string'
} as const,
{
'decode': (wire: string) => {
// Decode: parse URL to validate, then normalize via toString().
const url = new URL(wire);
return url.toString();
},
'encode': (canonicalUrlString: string) => {
// Encode: return the canonical URL string.
return canonicalUrlString;
}
}
);
jt.set(HrefTransform);
const wire = `https://bookstore.example/catalogue/${aboxFixtures.rareBook.isbn}`;
const decoded = jt.instantiate(HrefTransform, wire);
// Canonical is a normalized URL string.
console.assert(typeof decoded === 'string');
const url = new URL(decoded);
console.assert(url.pathname.endsWith(aboxFixtures.rareBook.isbn));
// 'bookstore.example'
console.log('decoded hostname:', url.hostname);
// '/catalogue/<isbn>'
console.log('decoded pathname:', url.pathname);
const reEncoded = jt.encode(HrefTransform, decoded);
console.assert(reEncoded === wire);
// true — string round-trip
console.log('round-trip:', reEncoded === wire);
Slug normalization
/**
* Transforms recipes — slug normalization
*
* Lowercase, strip non-alphanumerics, collapse spaces to dashes,
* trim leading/trailing dashes. The encoder is the identity, so
* the wire form keeps whatever the decoder produced. Registered
* as a new string primitive against `bookstoreEntities`.
*
* The wire string is the human-readable title of the 1979
* Thienemann first edition of Die unendliche Geschichte; the
* slug is suitable for review URLs.
*/
import { Transform } from '../../../src/index.js';
import {
aboxFixtures,
createBookstoreDocRegistry
} from '../bookstore/index.js';
// createBookstoreDocRegistry seeds a permissive copy of the bookstore — docs examples extend
// it with ad-hoc demo schemas; strict-graph checking is intentionally off here.
const jt = createBookstoreDocRegistry();
const SlugSchema = {
'$id': 'https://bookstore.example/Slug',
'type': 'string'
} as const;
jt.set(SlugSchema);
const SlugTransform = Transform.create<typeof SlugSchema, string>(SlugSchema, {
'decode': (raw) => {
return raw
.trim()
.toLowerCase()
.replaceAll(/[^a-z0-9]+/gu, '-')
.replaceAll(/^-|-$/gu, '');
},
'encode': (clean) => {
return clean;
}
});
const wire = ` ${aboxFixtures.rareBook.title}! `;
const slug = jt.instantiate(SlugTransform, wire);
console.assert(slug === 'die-unendliche-geschichte');
// 'Die unendliche Geschichte!' — messy input
console.log('wire:', wire.trim());
// 'die-unendliche-geschichte' — URL-safe
console.log('slug:', slug);
const reEncoded = jt.encode(SlugTransform, slug);
console.assert(reEncoded === slug);
// true — slug is its own wire form
console.log('encode is identity:', reEncoded === slug);
Pair with the custom slug format if you also want validation.
Encoded payloads
Base64 string to Uint8Array
/**
* Transforms recipes — base64 string ↔ canonical UTF-8 text string
*
* Wire format: `string` with `contentEncoding: 'base64'`. Canonical:
* UTF-8 text string. Decode base64 to text; encode re-encodes text to
* base64. Uses Node's `Buffer` (available everywhere the bookstore tests
* run); replace with `atob`/`btoa` on browser runtimes that lack `Buffer`.
*
* The payload is the UTF-8 text of "Bastian Balthazar Bux" —
* the customer record's owner name — decoded from base64.
*/
import { Transform } from '../../../src/index.js';
import {
aboxFixtures,
createBookstoreDocRegistry
} from '../bookstore/index.js';
// createBookstoreDocRegistry seeds a permissive copy of the bookstore — docs examples extend
// it with ad-hoc demo schemas; strict-graph checking is intentionally off here.
const jt = createBookstoreDocRegistry();
const BinaryTransform = Transform.create(
{
'$id': 'https://bookstore.example/Binary',
'contentEncoding': 'base64',
'type': 'string'
} as const,
{
'decode': (wire: string) => {
// Decode: base64 wire to canonical UTF-8 text string.
return Buffer.from(wire, 'base64').toString('utf8');
},
'encode': (textString: string) => {
// Encode: UTF-8 text string back to base64.
return Buffer.from(textString).toString('base64');
}
}
);
jt.set(BinaryTransform);
const original = new TextEncoder().encode(aboxFixtures.customer.name);
const wire = Buffer.from(original).toString('base64');
const decoded = jt.instantiate(BinaryTransform, wire);
// Canonical is a UTF-8 text string.
console.assert(typeof decoded === 'string');
console.assert(decoded === aboxFixtures.customer.name);
// base64 of "Bastian Balthazar Bux"
console.log('wire (base64):', wire);
// 'Bastian Balthazar Bux' — decoded text
console.log('decoded text:', decoded);
const reEncoded = jt.encode(BinaryTransform, decoded);
console.assert(reEncoded === wire);
// true — text → base64
console.log('round-trip:', reEncoded === wire);
For browsers, swap Buffer.from(b64, 'base64') for Uint8Array.from(atob(b64), c => c.charCodeAt(0)) and the encoder for btoa(String.fromCharCode(...bytes)).
JSON string to a parsed object
/**
* Transforms recipes — JSON string ↔ parsed object
*
* Wire format: JSON string. Canonical: parsed JavaScript object.
* The schema describes the canonical form (the parsed object).
* If callers want the decoded value to be more strictly validated,
* register the object schema separately and use a `$ref` rather
* than a generic `unknown` type.
*
* The payload is a serialized snapshot of the rare-book record
* Bastian ordered — the same object as `aboxFixtures.rareBook`.
*/
import { Transform } from '../../../src/index.js';
import {
aboxFixtures,
createBookstoreDocRegistry
} from '../bookstore/index.js';
// createBookstoreDocRegistry seeds a permissive copy of the bookstore — docs examples extend
// it with ad-hoc demo schemas; strict-graph checking is intentionally off here.
const jt = createBookstoreDocRegistry();
const JsonBlobTransform = Transform.create(
{
'$id': 'https://bookstore.example/JsonBlob',
'additionalProperties': true,
'type': 'object'
} as const,
{
'decode': (wire: string) => {
return JSON.parse(wire) as Record<string, unknown>;
},
'encode': (value: Record<string, unknown>) => {
return JSON.stringify(value);
}
}
);
jt.set(JsonBlobTransform);
const wire = JSON.stringify(aboxFixtures.rareBook);
const decoded = jt.instantiate(JsonBlobTransform, wire);
console.assert(typeof decoded === 'object');
console.assert((decoded as { 'isbn': string }).isbn === aboxFixtures.rareBook.isbn);
// same ISBN as fixture
console.log('decoded isbn:', (decoded as { 'isbn': string }).isbn);
// 'object' — JSON.parse returns the structure
console.log('decoded type:', typeof decoded);
const reEncoded = jt.encode(JsonBlobTransform, decoded);
console.assert(reEncoded === wire);
// true — JSON.stringify is deterministic here
console.log('round-trip equal:', reEncoded === wire);
Validation runs against the wire string. If you want the decoded value validated too, register the inner schema separately and use a $ref rather than a transform.
Collections
Comma-separated string to string[]
Wire format: 'fiction, paperback, bestseller'. Decoded type: string[].
/**
* Transforms recipes — comma-separated string ↔ `string[]`
*
* Wire format: `'fiction, paperback, bestseller'`. Decoded type:
* `string[]`. Registered as a new string primitive against
* `bookstoreEntities`.
*
* The wire string is a hand-tagged classification for the rare 1979
* Thienemann first edition Bastian ordered.
*/
import { Transform } from '../../../src/index.js';
import { createBookstoreDocRegistry } from '../bookstore/index.js';
// createBookstoreDocRegistry seeds a permissive copy of the bookstore — docs examples extend
// it with ad-hoc demo schemas; strict-graph checking is intentionally off here.
const jt = createBookstoreDocRegistry();
const TagListTransform = Transform.create(
{
'$id': 'https://bookstore.example/TagList',
'items': { 'type': 'string' },
'type': 'array'
} as const,
{
'decode': (raw: string) => {
return raw
.split(',')
.map((tag: string) => {
return tag.trim();
})
.filter(Boolean);
},
'encode': (tags: readonly string[]) => {
return tags.join(', ');
}
}
);
jt.set(TagListTransform);
const wire = 'fantasy, rare, first-edition, hardcover';
const tags = jt.instantiate(TagListTransform, wire);
console.assert(Array.isArray(tags));
console.assert(tags[0] === 'fantasy');
console.assert(tags.length === 4);
// 'fantasy, rare, first-edition, hardcover'
console.log('wire:', wire);
// ['fantasy', 'rare', 'first-edition', 'hardcover']
console.log('tags:', tags);
const reEncoded = jt.encode(TagListTransform, tags);
console.assert(reEncoded === wire);
// 'fantasy, rare, first-edition, hardcover'
console.log('round-trip:', reEncoded);
If both ends of the wire are an array, prefer a plain type: 'array' schema with no transform.
Branded types
Branded primitive plus decode
Transform.brand attaches a phantom brand to the inferred type without changing the wire format. Compose it with Transform.create when you also need a runtime conversion.
/**
* Transforms recipes — branded primitive via Transform.brand
*
* `Transform.brand` attaches a phantom brand to a schema's inferred
* type without changing the wire format. The runtime value is still
* a plain string; the compile-time type carries the brand, so a
* generic `string` cannot be passed where an `Isbn` is expected.
*
* The wire is the ISBN-13 of the 1979 Thienemann first edition of
* Michael Ende's Die unendliche Geschichte (the same value Bastian's
* order references). Registered as a sibling of the canonical
* `IsbnSchema` so the canonical primitive stays brand-free.
*/
import {
Compose, Transform
} from '../../../src/index.js';
import {
aboxFixtures, createBookstoreDocRegistry,
IsbnSchema
} from '../bookstore/index.js';
// createBookstoreDocRegistry seeds a permissive copy of the bookstore — docs examples extend
// it with ad-hoc demo schemas; strict-graph checking is intentionally off here.
const jt = createBookstoreDocRegistry();
const BrandedIsbnBase = Compose.equivalent(
IsbnSchema,
{ '$id': 'https://bookstore.example/BrandedIsbn' } as const
);
const jt2 = jt.set(BrandedIsbnBase);
const BrandedIsbnSchema = Transform.brand(BrandedIsbnBase, 'BrandedIsbn');
const wire = aboxFixtures.rareBook.isbn;
const decoded = jt2.instantiate(BrandedIsbnSchema, wire);
console.assert(typeof decoded === 'string');
console.assert(decoded === wire);
// '9783522128001'
console.log('wire ISBN:', wire);
// same string — brand is compile-time only
console.log('decoded (branded):', decoded);
// 'string' — no runtime difference
console.log('typeof decoded:', typeof decoded);
void BrandedIsbnSchema;
To brand AND convert, chain via Transform.create on the branded schema.
Round-trip discipline
A transform is lossless when encode(decode(x)) === x and decode(encode(y)) === y for every value in the domain. Recipes in this page that pass this test:
- ISO 8601 date-time to
Date(string-form normalizes, but every valid input maps to a unique output). - Unix epoch milliseconds.
- Temporal
PlainDate. - Cents to
Decimal. - Branded primitives.
- Pure normalization where the encoder is the identity (email lowercase, slug) - lossy in one direction by design.
If your recipe is lossy, document which direction loses information and what the canonical form is.
Property test pattern
/**
* Transforms recipes — round-trip property test pattern
*
* A transform is lossless when `encode(decode(x)) === x` and
* `decode(encode(y)) === y` for every value in the domain. This
* file demonstrates the property-test pattern against the
* `PlacedAt` transform registered in `03-transforms-recipes.ts`
* — the ISO 8601 string ↔ canonical ISO 8601 string pair.
*
* The samples include the canonical Bastian-order timestamp and
* two neighbouring instants to exercise non-trivial inputs.
*/
import {
Transform
} from '../../../src/index.js';
import type { TransformedType } from '../../../src/types/Transform.js';
import {
aboxFixtures, createBookstoreDocRegistry
} from '../bookstore/index.js';
// Browser-safe strict equality assertion (same shape as node:assert.strict),
// so this property test runs anywhere, not just under Node.
const assert = {
equal(actual: unknown, expected: unknown, message?: string): void {
if (actual !== expected) {
throw new Error(message ?? `expected ${String(actual)} to equal ${String(expected)}`);
}
}
};
// createBookstoreDocRegistry seeds a permissive copy of the bookstore — docs examples extend
// it with ad-hoc demo schemas; strict-graph checking is intentionally off here.
const jt = createBookstoreDocRegistry();
const RoundTripPlacedAtTransform = Transform.create(
{
'$id': 'https://bookstore.example/RoundTripPlacedAt',
'format': 'date-time',
'type': 'string'
} as const,
{
'decode': (wire: string) => {
// Decode: normalize any ISO 8601 input to canonical Date.toISOString() form.
return new Date(wire).toISOString();
},
'encode': (isoString: string) => {
// Encode: return the canonical ISO string.
return isoString;
}
}
);
jt.set(RoundTripPlacedAtTransform);
function roundTrip(
schema: TransformedType<
{
readonly '$id': 'https://bookstore.example/RoundTripPlacedAt';
readonly 'format': 'date-time';
readonly 'type': 'string';
},
string
>,
samples: readonly string[]
): void {
for (const wire of samples) {
const decoded = jt.instantiate(schema, wire);
const reEncoded = jt.encode(schema, decoded);
assert.equal(reEncoded, wire);
}
}
// Each sample is already in the canonical Date.toISOString() form,
// so encode(decode(x)) === x exactly. The bookstore fixture timestamp
// is normalized to the same form for the round-trip.
const normalizedPlacedAt = new Date(aboxFixtures.order.placedAt).toISOString();
const samples = [
normalizedPlacedAt,
'2026-01-15T10:30:00.000Z',
'1979-09-01T00:00:00.000Z'
];
roundTrip(RoundTripPlacedAtTransform, samples);
console.log('round-trip samples checked:', samples.length);
console.log('all encode(decode(x)) === x:', true);
When NOT to use a transform
- The wire format is already the desired runtime type. Use a plain schema and skip the transform.
- You need cross-field logic. Use
addInvariantorjt:computed. - You only want to filter unknown properties. Use
enableTypeCastorCompose.pick. - You want different runtime types per consumer. A transform is global to the schema's
$id. Use sibling schemas if call sites need different decoded shapes.
Related
Transform.createandjt.encode- the underlying APITransform.chain- multi-stage chains, decode/encode directionTransform.brand- nominal typing without runtime conversion- Custom formats - validate the wire format before decoding
See also
- Bookstore domain - where
IsbnSchema,Money,OrderSchemaare defined - Sub-schemas and
$refcomposition - registering once, referencing everywhere - Picking a method - when to use
instantiatevsvalidatevsmaterialize