Transform.create and jt.encode Compile-time + Runtime
Validation modes: Validation modes reference
Transform.create and jt.encode are a symmetric pair: create attaches decode/encode functions to a schema, and jt.encode uses the registered encode function to convert a domain value back to wire form.
Transform.create
Declaration. Attaches decode and encode functions to a schema using a WeakMap (the schema object is never mutated). Returns the same schema object with a widened TypeScript type TransformedType<TSchema, TOut>. After Transform.create, any call to jt.instantiate(schema.$id, raw) automatically applies the decode function before validation — the schema describes decode's output, so validation (which fills defaults and strips unknown properties) runs on the decoded result. See Canonical decode/default ordering for the full sequence. The TypeScript return type changes from InferSchemaType<TSchema> to TOut.
decode is typed (raw: TWire) => Partial<CanonicalShapeType<TSchema, TReferences>> — it may return just the fields it actually transforms, not the full canonical shape. encode is typed (value: CanonicalShapeType<TSchema, TReferences>) => TWire and always consumes the full canonical value, since encode runs on the validated, fully-defaulted result. See Partial decode with enableDefaults below.
Use this when a wire-format value needs automatic conversion to a richer domain type - ISO date strings → Date, cents integers → floats, raw enums → branded enums, base64 strings → Buffer.
Don't use this when you want multiple sequential transformations (use chain instead). Don't use it for nominal typing without runtime conversion (use brand).
Examples
Example 1: ISO datetime to Date - full round-trip
/**
* Transform.create / encode — Example 1: raw date string ↔ canonical ISO
* Demonstrates: decode normalizes wire → canonical, encode reversal, DecodeError
* on malformed input.
*
* A normalize transform's `decode` turns the raw wire value into the schema's
* canonical form; the schema describes decode's OUTPUT, so validation runs on
* the decoded result (decode → validate → strip). The canonical value is the
* moment Bastian Balthazar Bux placed their order for the 1979 Neverending
* Story from Coreander's antiquariat — `aboxFixtures.order.placedAt`.
*/
import {
DecodeError, 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 PlacedAtSchema = Transform.create(
{
'$id': 'https://bookstore.example/PlacedAt',
'format': 'date-time',
'type': 'string'
} as const,
{
// Wire (a raw date string) → canonical ISO date-time string. A malformed
// input fails inside decode, before validation, surfacing a DecodeError.
'decode': (raw: string) => {
return new Date(raw).toISOString();
},
'encode': (isoString) => {
return isoString;
}
}
);
jt.set(PlacedAtSchema);
// Wire → canonical. The decoded value is the canonical ISO date-time string.
const raw = '2026-04-12T14:23:11.000Z';
const decoded = jt.instantiate(PlacedAtSchema, raw);
console.assert(typeof decoded === 'string');
// Same instant as `aboxFixtures.order.placedAt`.
console.assert(decoded === new Date(aboxFixtures.order.placedAt).toISOString());
// decoded: 2026-04-12T14:23:11.000Z
console.log('decoded :', decoded);
// Canonical → wire (encode reversal).
const wire = jt.encode(PlacedAtSchema, decoded);
console.assert(wire === raw);
// encoded: 2026-04-12T14:23:11.000Z (exact round-trip)
console.log('encoded :', wire);
// Malformed input fails inside decode → DecodeError.
let threw = false;
try {
// PlacedAtSchema was registered at runtime via set(), so it is not part of
// the registry's compile-time schema-ID union — pass the schema object.
jt.instantiate(PlacedAtSchema, 'not-a-date');
} catch (error) {
threw = error instanceof DecodeError;
}
console.assert(threw);
console.log('malformed input threw DecodeError:', threw);
Example 2: Price in cents to decimal
/**
* Transform.create — Example 2: Price in cents to decimal
* Demonstrates: decode cents → float, encode float → cents, bookstore registry
*
* A cents-based integer schema registers onto the canonical bookstore. The
* fixture price is the cover price of Cornelia Funke's Tintenherz (Cecilie
* Dressler Verlag, 2003) in EUR cents — 1499 → 14.99 after decode.
*/
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 PriceCentsSchema = Transform.create(
{
'$id': 'https://bookstore.example/PriceCents',
'minimum': 0,
'type': 'number'
} as const,
{
'decode': (cents: number) => {
return cents / 100;
},
'encode': (dollars: number) => {
return Math.round(dollars * 100);
}
}
);
jt.set(PriceCentsSchema);
const price = jt.instantiate(PriceCentsSchema, 1499);
console.assert(price === 14.99);
// 1499 cents decoded to EUR: 14.99
console.log('1499 cents decoded to EUR:', price);
const wire = jt.encode(PriceCentsSchema, price);
console.assert(wire === 1499);
console.log('14.99 EUR encoded to cents:', wire);
Example 3: jt.addTransform — registry-aware transform registration
jt.addTransform(schema, { decode, encode }) is the instance-bound counterpart to Transform.create. The key difference: decode input types resolve cross-registry $refs through the instance's schema map, so a schema whose properties $ref registered primitives gets a fully-typed decode input — no cast, no unknown.
/**
* Advanced Example 111 — addTransform: registry-aware transform registration
*
* `jt.addTransform(schema, { decode, encode })` is the instance-bound
* alternative to the static `Transform.create`. The key difference:
* `decode` input types resolve cross-registry `$ref`s through the
* instance's schema map, so a schema whose properties `$ref` registered
* primitives gets a fully-typed decode input — no cast, no `unknown`.
*
* Here a `PriceCents` wire schema (integer cents) is registered alongside
* a `Currency` string primitive. `addTransform` decodes cents → a typed
* `{ amount: number; currency: string }` domain value and encodes it back
* to cents for the wire. The decoded value is then round-tripped through
* `jt.encode()`.
*/
import { JsonTology } from '../../../src/index.js';
// ── Schema definitions ────────────────────────────────────────────────────
const CurrencySchema = {
'$id': 'https://bookstore.example/Currency',
'minLength': 3,
'type': 'string'
} as const;
const PriceCentsSchema = {
'$id': 'https://bookstore.example/PriceCents',
'minimum': 0,
'properties': {
'currency': { '$ref': 'https://bookstore.example/Currency' },
'valueInCents': { 'type': 'integer' }
},
'required': [
'currency',
'valueInCents'
],
'type': 'object'
} as const;
// ── Registry ──────────────────────────────────────────────────────────────
const jt = JsonTology.create({
'baseIri': 'https://bookstore.example',
'enableStrictGraph': false,
'schemas': [
CurrencySchema,
PriceCentsSchema
] as const
});
// ── Register the transform ────────────────────────────────────────────────
// `input.currency` is typed `string` and `input.valueInCents` is typed
// `number` because `$ref: 'Currency'` resolved through TRefs.
// The decode output must be a plain JSON value matching the schema's canonical form.
const PriceCentsCodec = jt.addTransform(PriceCentsSchema, {
'decode': (input): { 'currency': string;
'valueInCents': number } => {
// Normalize: round up cents to nearest integer and validate range.
const roundedCents = Math.round(input.valueInCents);
return {
'currency': input.currency,
'valueInCents': roundedCents
};
},
'encode': (value: { 'currency': string;
'valueInCents': number }): {
'currency': string;
'valueInCents': number
} => {
// Encode reversal: return to wire form.
return {
'currency': value.currency,
'valueInCents': value.valueInCents
};
}
});
// ── instantiate: wire → canonical form ─────────────────────────────────────
const wire = {
'currency': 'EUR',
'valueInCents': 1999
};
const canonical = jt.instantiate(PriceCentsCodec, wire);
console.assert(canonical.valueInCents === 1999, 'canonical cents');
console.assert(canonical.currency === 'EUR', 'canonical currency');
console.log('Canonical:', canonical);
// ── encode: canonical → wire ──────────────────────────────────────────────────
const reEncoded = jt.encode(PriceCentsCodec, canonical);
console.assert(reEncoded.valueInCents === 1999, 'encoded cents');
console.assert(reEncoded.currency === 'EUR', 'encoded currency');
console.log('Re-encoded:', reEncoded);
// ── round-trip assertion ───────────────────────────────────────────────────
console.assert(
reEncoded.valueInCents === wire.valueInCents,
'round-trip: decoded then re-encoded equals original wire cents'
);
console.assert(
reEncoded.currency === wire.currency,
'round-trip: decoded then re-encoded equals original wire currency'
);
console.log('Round-trip matches original wire:', reEncoded.valueInCents === wire.valueInCents);
Partial decode with enableDefaults
decode does not have to produce the full canonical value. instantiate runs decode before validation, so when the caller passes { enableDefaults: true }, schema defaults fill any required field decode left out — the same completion path instantiate uses for any other omitted field; see Canonical decode/default ordering. A decoder whose only job is coercing one or two wire fields (env-var-style strings → numbers, for example) can return just those fields instead of re-declaring every schema default or reaching for a type assertion.
This is narrower than Compose.getDefaults — getDefaults only reads a schema's declared defaults into a plain object for form pre-population; it is not the completion mechanism for a partial decode result, and does not run decode or validation at all.
Example 4: Env-var-style quantity coercion, rest filled by defaults
/**
* Transform.create — Example: partial decode completed by `enableDefaults`
* Demonstrates: decode returning Partial<CanonicalShapeType<...>>, the rest
* filled by schema `default`s when `instantiate` runs with
* `{ enableDefaults: true }`.
*
* `decode` only coerces the one wire field that actually needs conversion —
* `quantity` arrives as an env-var-style string on a restock request wire —
* and passes `sku` through untouched. It does not fill `warehouse` or
* `priority`; those have schema `default`s, and `instantiate` fills them
* during the validation pass that runs after `decode`.
*/
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 RestockRequestSchema = {
'$id': 'https://bookstore.example/RestockRequest',
'properties': {
'priority': {
'default': 'normal',
'type': 'string'
},
'quantity': {
'minimum': 1,
'type': 'integer'
},
'sku': { 'type': 'string' },
'warehouse': {
'default': 'central',
'type': 'string'
}
},
'required': [
'sku',
'quantity',
'warehouse',
'priority'
],
'type': 'object'
} as const;
const RestockRequestCodec = Transform.create(RestockRequestSchema, {
// Only `quantity` needs conversion — the wire carries it as an env-var-style
// string. `sku` passes through unchanged. `warehouse` and `priority` are
// left out entirely: they have schema `default`s, so `enableDefaults: true`
// fills them after decode runs.
'decode': (raw: { 'quantity': string;
'sku': string; }) => {
return {
'quantity': Number.parseInt(raw.quantity, 10),
'sku': raw.sku
};
},
'encode': (value) => {
return {
'priority': value.priority,
'quantity': String(value.quantity),
'sku': value.sku,
'warehouse': value.warehouse
};
}
});
jt.set(RestockRequestCodec);
const restock = jt.instantiate(
RestockRequestCodec,
{
'quantity': '50',
'sku': '9780743273565-RESTOCK'
},
{ 'enableDefaults': true }
);
console.assert(restock.sku === '9780743273565-RESTOCK');
console.assert(restock.quantity === 50, 'wire string "50" coerced to number by decode');
console.assert(restock.warehouse === 'central', 'default filled in after the partial decode ran');
console.assert(restock.priority === 'normal', 'default filled in after the partial decode ran');
// decode returned: { sku: '9780743273565-RESTOCK', quantity: 50 }
console.log('decode returned:', {
'quantity': 50,
'sku': '9780743273565-RESTOCK'
});
// instantiate returned (defaults filled after decode): { sku: ..., quantity: 50, warehouse: 'central', priority: 'normal' }
console.log('instantiate returned (defaults filled after decode):', restock);
Bad examples - what NOT to do
Anti-pattern 1: Applying transform after the schema was registered
/**
* jt.encode — Example 1: Round-trip a placement timestamp
* Demonstrates: instantiate (wire → canonical), encode (canonical → wire), exact round-trip
*
* A normalize transform: decode turns a wire ISO string into the schema's canonical ISO form,
* encode returns it to wire format. The timestamp is the moment Bastian Balthazar Bux placed
* their order for the 1979 Thienemann first edition from Coreander's antiquariat.
*/
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 PlacedAtRoundTripSchema = Transform.create(
{
'$id': 'https://bookstore.example/PlacedAtRoundTrip',
'format': 'date-time',
'type': 'string'
} as const,
{
'decode': (isoString: string) => {
return new Date(isoString).toISOString();
},
'encode': (isoString: string) => {
return isoString;
}
}
);
jt.set(PlacedAtRoundTripSchema);
const raw = '2026-01-15T10:30:00.000Z';
const canonical = jt.instantiate(PlacedAtRoundTripSchema, raw);
if (typeof canonical !== 'string') {
throw new TypeError('Expected string from decode');
}
const wire = jt.encode(PlacedAtRoundTripSchema, canonical);
console.assert(wire === raw);
console.assert(typeof wire === 'string');
console.log('wire :', raw);
console.log('canonical :', canonical);
// exact round-trip
console.log('re-encoded === wire:', wire === raw);
Comparison
const DateSchema = Transform.create(
{ $id: 'https://bookstore.example/PlacedAt', type: 'string', format: 'date-time' } as const,
{
decode: (isoStr: string) => new Date(isoStr),
encode: (dateVal: Date) => dateVal.toISOString()
},
);
// jt.instantiate(DateSchema.$id, '2026-01-15T10:30:00Z') → Date
// jt.encode(DateSchema, date) → '2026-01-15T10:30:00Z'
// Note: json-tology enforces strict RFC 3339 for date-time — a time offset
// (Z or ±HH:MM) is required. Offset-less strings (e.g. '2026-01-15T10:30:00')
// are rejected at validation time.const DateSchema = z.string().datetime().transform(s => new Date(s));
DateSchema.parse('2026-01-15T10:30:00Z'); // → Date
// No built-in encode step - call .toISOString() manually for the reverse.import * as v from 'valibot';
const DateSchema = v.pipe(
v.string(),
v.isoDateTime(),
v.transform((s) => new Date(s)),
);
v.parse(DateSchema, '2026-01-15T10:30:00Z'); // → Date
// Limitation: Valibot has no first-class encode direction. Define a
// separate inverse schema or call dateVal.toISOString() manually.import * as t from 'io-ts';
import { isLeft } from 'fp-ts/Either';
const DateCodec = new t.Type<Date, string, unknown>(
'DateFromIsoString',
(input): input is Date => input instanceof Date,
(input, ctx) => typeof input === 'string' && !Number.isNaN(Date.parse(input))
? t.success(new Date(input))
: t.failure(input, ctx),
(date) => date.toISOString(),
);
const decoded = DateCodec.decode('2026-01-15T10:30:00Z'); // Either<Errors, Date>
if (!isLeft(decoded)) { /* decoded.right is Date */ }
const wire = DateCodec.encode(new Date()); // Date → ISO string
// io-ts codecs carry symmetric .decode and .encode; compose with t.union /
// t.intersection to build larger transforms.// TypeBox validates only - no decode/encode transform mechanism.
// Apply manually after validation:
const C = TypeCompiler.Compile(Type.String({ format: 'date-time' }));
if (C.Check(raw)) {
const date = new Date(raw); // manual decode
}// AJV validates only - no decode/encode.
if (ajv.validate({ type: 'string', format: 'date-time' }, raw)) {
const date = new Date(raw); // manual
}from datetime import datetime
class Order(BaseModel):
placed_at: datetime # Pydantic auto-converts ISO strings to datetime
# model_dump(mode='json') serializes datetime back to ISO string// Limitation: feature not directly supported in Yup. See /comparisons for the matrix.// Limitation: feature not directly supported in Joi. See /comparisons for the matrix.// Limitation: feature not directly supported in Effect Schema. See /comparisons for the matrix.// Limitation: feature not directly supported in ArkType. See /comparisons for the matrix.// Limitation: feature not directly supported in Runtypes. See /comparisons for the matrix.Related
jt.encode- apply the encode function (domain → wire)chain- compose multiple transformation stepsbrand- compile-time nominal typing without runtime decodedump- appliesencodeduring schema graph traversal
jt.encode
Declaration. Applies the encode function registered on schema via Transform.create or Transform.chain. Converts a decoded domain value back to its wire representation. Returns the brand-free wire InputType (LooseInputType<InferSchemaType<TSchema>>) — encode runs on the way out to the wire, where validation brands do not exist. If no transform is registered on the schema, returns the value unchanged.
Use this when you have a decoded domain value (e.g. a Date object) and need the wire form (e.g. ISO string) for storage, HTTP response, or queue message.
Don't use this when you want to serialize a whole object graph - use dump which walks the schema graph and applies encode to each transformed property.
Examples
Example 1: Round-trip a placement timestamp
/**
* jt.encode — Example 1: Round-trip a placement timestamp
* Demonstrates: instantiate (wire → canonical), encode (canonical → wire), exact round-trip
*
* A normalize transform: decode turns a wire ISO string into the schema's canonical ISO form,
* encode returns it to wire format. The timestamp is the moment Bastian Balthazar Bux placed
* their order for the 1979 Thienemann first edition from Coreander's antiquariat.
*/
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 PlacedAtRoundTripSchema = Transform.create(
{
'$id': 'https://bookstore.example/PlacedAtRoundTrip',
'format': 'date-time',
'type': 'string'
} as const,
{
'decode': (isoString: string) => {
return new Date(isoString).toISOString();
},
'encode': (isoString: string) => {
return isoString;
}
}
);
jt.set(PlacedAtRoundTripSchema);
const raw = '2026-01-15T10:30:00.000Z';
const canonical = jt.instantiate(PlacedAtRoundTripSchema, raw);
if (typeof canonical !== 'string') {
throw new TypeError('Expected string from decode');
}
const wire = jt.encode(PlacedAtRoundTripSchema, canonical);
console.assert(wire === raw);
console.assert(typeof wire === 'string');
console.log('wire :', raw);
console.log('canonical :', canonical);
// exact round-trip
console.log('re-encoded === wire:', wire === raw);
Example 2: Serialize before database write
/**
* jt.encode — Example 2: Serialize before database write
* Demonstrates: encode applied to a canonical ISO string before persisting to wire format
*
* The canonical form holds the timestamp as an ISO string. Before writing to the database,
* the encode step converts it to wire format (here, the same ISO string). The example uses
* the Bastian Balthazar Bux order fixture from Coreander's antiquariat.
*/
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 PlacedAtDbSchema = Transform.create(
{
'$id': 'https://bookstore.example/PlacedAtDb',
'format': 'date-time',
'type': 'string'
} as const,
{
'decode': (isoString: string) => {
return new Date(isoString).toISOString();
},
'encode': (isoString: string) => {
return isoString;
}
}
);
jt.set(PlacedAtDbSchema);
// PlacedAtDbSchema was registered at runtime via set(), so it is not part of
// the registry's compile-time schema-ID union — pass the schema object. The
// transform decodes the ISO string to canonical form.
const canonical = jt.instantiate(
PlacedAtDbSchema,
aboxFixtures.order.placedAt
);
// Before writing to DB — encode to wire format.
const placedAtWire = jt.encode(PlacedAtDbSchema, canonical);
console.assert(typeof placedAtWire === 'string');
console.assert(placedAtWire === new Date(aboxFixtures.order.placedAt).toISOString());
console.log('canonical form :', canonical);
// ISO string ready for persistence
console.log('db-ready wire :', placedAtWire);
Comparison
const wire = jt.encode(PlacedAtSchema, date); // Date → string// Zod has no built-in encode step; call manually:
const wire = date.toISOString();
// Limitation: encode is decoupled from schema - the reverse transformation
// is not registered anywhere; callers must remember which function to call per type.import * as v from 'valibot';
// Limitation: Valibot has no schema-registered encode step.
// Apply the inverse transformation manually:
const wire = (date as Date).toISOString();const wire = DateCodec.encode(date); // domain → wire, schema-registered
// Symmetric with .decode; no separate facade needed.// TypeBox has no built-in encode mechanism.
// Apply the encode transformation manually:
const wire = (date as Date).toISOString();
// Limitation: encode step is not schema-associated; every call site must know
// which encode function applies. No round-trip guarantee without discipline.// AJV has no built-in encode mechanism. Apply manually:
const wire = (date as Date).toISOString();
// Limitation: same as TypeBox - encode is not schema-registered;
// no symmetric round-trip guarantee.# model_dump(mode='json') serializes datetime to ISO string:
wire = order.model_dump(mode='json')['placed_at'] # str// Limitation: feature not directly supported in Yup. See /comparisons for the matrix.// Limitation: feature not directly supported in Joi. See /comparisons for the matrix.// Limitation: feature not directly supported in Effect Schema. See /comparisons for the matrix.// Limitation: feature not directly supported in ArkType. See /comparisons for the matrix.// Limitation: feature not directly supported in Runtypes. See /comparisons for the matrix.Related
Transform.create- where the encode function is registereddump- appliesencodewhile walking the full schema graph
Error handling
When a decode transform throws, jt.instantiate wraps the failure in a DecodeError (code TRANSFORM_DECODE_FAILED, direction 'decode'). When an encode transform throws, jt.encode wraps it in an EncodeError (code TRANSFORM_ENCODE_FAILED, direction 'encode'). Both extend TransformError, which extends BaseError, so every field on the base class (code, message, cause, retryable) is available.
Custom decode or encode functions may throw DecodeError or EncodeError directly. The library propagates the thrown instance unchanged: message, code, and any path or schemaId set by the caller are preserved. The library fills in missing schemaId context automatically.
/**
* Transform errors — Example: DecodeError, EncodeError, CoercionError
* Demonstrates: typed error handling for decode/encode failures and value.cast
*
* Three scenarios from Coreander's antiquariat:
* a. A decode transform rejects a malformed placement timestamp → DecodeError.
* b. A custom decoder throws its own DecodeError → library preserves the instance.
* c. An encode transform throws → EncodeError caught from jt.encode.
* d. value.cast on data that cannot satisfy the schema → CoercionError.
*/
import {
CoercionError,
DecodeError,
EncodeError,
Transform,
TransformError
} from '../../../src/index.js';
import {
BookSchema,
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();
// ─── a. Decode transform rejects bad input → DecodeError ────────────────────
const StrictDateSchema = Transform.create(
{
'$id': 'https://bookstore.example/StrictDate',
'format': 'date-time',
'type': 'string'
} as const,
{
'decode': (raw: string) => {
const ms = Date.parse(raw);
if (Number.isNaN(ms)) {
throw new TypeError(`Not a valid date string: "${raw}"`);
}
return new Date(ms).toISOString();
},
'encode': (isoString: string) => {
return isoString;
}
}
);
jt.set(StrictDateSchema);
try {
jt.instantiate(StrictDateSchema, 'not-a-date');
} catch (error) {
if (error instanceof DecodeError) {
console.log('a. DecodeError caught');
console.log(' code :', error.code);
console.log(' direction :', error.direction);
console.log(' cause :', error.cause?.message);
}
}
// ─── b. Custom decoder throws DecodeError → instance preserved ───────────────
const AnnotatedDateSchema = Transform.create(
{
'$id': 'https://bookstore.example/AnnotatedDate',
'format': 'date-time',
'type': 'string'
} as const,
{
'decode': (raw: string) => {
if (!raw.startsWith('19') && !raw.startsWith('20')) {
throw new DecodeError('Year out of antiquariat range', {
'code': 'TRANSFORM_DECODE_FAILED',
'direction': 'decode',
'path': '/placedAt'
});
}
return new Date(raw).toISOString();
},
'encode': (isoString: string) => {
return isoString;
}
}
);
jt.set(AnnotatedDateSchema);
try {
jt.instantiate(AnnotatedDateSchema, '1800-01-01T00:00:00Z');
} catch (error) {
if (error instanceof DecodeError) {
console.log('b. Custom DecodeError preserved');
console.log(' message :', error.message);
console.log(' path :', error.path);
console.log(' is TransformError:', error instanceof TransformError);
}
}
// ─── c. Encode transform throws → EncodeError ───────────────────────────────
const GuardedEncodeSchema = Transform.create(
{
'$id': 'https://bookstore.example/GuardedEncode',
'format': 'date-time',
'type': 'string'
} as const,
{
'decode': (raw: string) => {
return new Date(raw).toISOString();
},
'encode': (isoString: string) => {
const date = new Date(isoString);
if (date.getFullYear() < 1900) {
throw new Error('Cannot encode dates before 1900 to wire format');
}
return isoString;
}
}
);
jt.set(GuardedEncodeSchema);
const ancientDate = '1879-01-01T00:00:00Z';
try {
jt.encode(GuardedEncodeSchema, ancientDate);
} catch (error) {
if (error instanceof EncodeError) {
console.log('c. EncodeError caught');
console.log(' code :', error.code);
console.log(' direction :', error.direction);
console.log(' cause :', error.cause?.message);
}
}
// ─── d. value.cast on uncoercible data → CoercionError ──────────────────────
// BookSchema requires 'title', 'isbn', 'authors', 'price', 'printStatus'.
// Passing an empty object fails coercion.
try {
jt.value.cast(BookSchema.$id, {});
} catch (error) {
if (error instanceof CoercionError) {
console.log('d. CoercionError caught');
console.log(' code :', error.code);
console.log(' errors.length:', error.errors.items.length);
}
}
See Error class hierarchy for the full reference on TransformError, DecodeError, EncodeError, and CoercionError.
See also
- Bookstore domain - schemas referenced in examples