Skip to content

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());
Output
Press Execute to run this example against the real library.

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);
Output
Press Execute to run this example against the real library.

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);
Output
Press Execute to run this example against the real library.

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);
Output
Press Execute to run this example against the real library.

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);
Output
Press Execute to run this example against the real library.

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);
Output
Press Execute to run this example against the real library.

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);
Output
Press Execute to run this example against the real library.

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);
Output
Press Execute to run this example against the real library.

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);
Output
Press Execute to run this example against the real library.

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);
Output
Press Execute to run this example against the real library.

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);
Output
Press Execute to run this example against the real library.

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);
Output
Press Execute to run this example against the real library.

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);
Output
Press Execute to run this example against the real library.

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;
Output
Press Execute to run this example against the real library.

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);
Output
Press Execute to run this example against the real library.

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 addInvariant or jt:computed.
  • You only want to filter unknown properties. Use enableTypeCast or Compose.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.

See also

Released under the MIT License.