Transform.chain Compile-time + Runtime
Validation modes: Validation modes reference
Declaration. Composes multiple decode/encode function pairs into a single transform chain attached to a schema. Decode runs left-to-right through the array; encode runs right-to-left. The schema object is never mutated - the chain is stored in a WeakMap keyed by the schema object. Returns TransformedType<TSchema, TOut>.
Use this when a single wire value requires sequential transformation steps - for example, stripping formatting characters from a price string, then parsing the result to a float. Or decoding a compressed/encoded field in two passes.
Don't use this when a single decode/encode pair is sufficient (use Transform.create instead - simpler, clearer intent). Don't use it for nominal typing without conversion (use Transform.brand).
Examples
Example 1: Formatted price string to float (two steps)
/**
* Transform.chain — Example 1: Multi-step string → float transform
* Demonstrates: left-to-right decode chain, right-to-left encode chain
*
* The transform schema registers onto the canonical bookstore via
* `jt.set()`. The price string is the wire form of
* Bastian Balthazar Bux's rare 1979 Thienemann edition of Michael Ende's
* Die unendliche Geschichte (€850.00).
*/
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 PricedSchema = Transform.chain(
{
'$id': 'https://bookstore.example/Priced',
'type': 'number'
} as const,
[
// Step 1: strip currency symbol and thousands separators.
{
'decode': (rawInput: unknown) => {
return (rawInput as string).replaceAll(/[€,]/gu, '');
},
'encode': (rawInput: unknown) => {
return `€${rawInput as string}`;
}
},
// Step 2: parse to float / format to two decimal places.
{
'decode': (rawInput: unknown) => {
return Number.parseFloat(rawInput as string);
},
'encode': (numInput: unknown) => {
return (numInput as number).toFixed(2);
}
}
]
);
jt.set(PricedSchema);
const price = jt.instantiate(PricedSchema, '€850.00');
console.assert(price === 850);
console.assert(price === aboxFixtures.rareBook.price.amount);
// decoded price: 850 (float)
console.log('decoded price :', price);
const wire = jt.encode(PricedSchema, price);
console.assert(wire === '€850.00');
// encoded wire: €850.00
console.log('encoded wire :', wire);
Example 2: Decode direction is left-to-right, encode is right-to-left
/**
* Transform.chain — Example 2: Decode direction left-to-right, encode right-to-left
* Demonstrates: three-step chain with correct stage ordering against bookstore
*
* Three steps: (1) strip currency symbol, (2) strip whitespace, (3) parse to float.
* Encode runs in reverse: float → fixed-string → prefix € → prefix "Price: ".
* The fixture is the cover price of Patrick Süskind's Das Parfum (Diogenes, 1985)
* in a formatted display string.
*/
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();
// Decode: A.decode → B.decode → C.decode = canonical (number)
// Encode: C.encode → B.encode → A.encode = wire (string)
const ChainedPriceSchema = Transform.chain(
{
'$id': 'https://bookstore.example/ChainedPrice',
'type': 'number'
} as const,
[
// Step A: strip "Price: " prefix
{
'decode': (rawInput: unknown) => {
return (rawInput as string).replace('Price: ', '');
},
'encode': (rawInput: unknown) => {
return `Price: ${rawInput as string}`;
}
},
// Step B: strip currency symbol
{
'decode': (rawInput: unknown) => {
return (rawInput as string).replace('€', '').trim();
},
'encode': (rawInput: unknown) => {
return `€${rawInput as string}`;
}
},
// Step C: parse to float / format to two decimal places
{
'decode': (rawInput: unknown) => {
return Number.parseFloat(rawInput as string);
},
'encode': (numInput: unknown) => {
return (numInput as number).toFixed(2);
}
}
]
);
jt.set(ChainedPriceSchema);
const price = jt.instantiate(ChainedPriceSchema, 'Price: €24.95');
console.assert(price === 24.95);
// decoded (A→B→C): 24.95
console.log('decoded (A→B→C):', price);
const wire = jt.encode(ChainedPriceSchema, price);
console.assert(wire === 'Price: €24.95');
// encoded (C→B→A): Price: €24.95
console.log('encoded (C→B→A):', wire);
Pairwise chain compatibility Compile-time
Transform.chain enforces stage-to-stage type compatibility at the call site. Each stage is typed as TransformStageInterface<TIn, TOut>. The output type of stage N must be assignable to the input type of stage N+1. When a mismatch is detected, the incompatible stage position is replaced with a ChainMismatchInterface<index, produced, expected> brand - the compiler rejects the call and the IDE hover explains which stage is incompatible.
The first stage is also checked against the schema's wire type. A mismatch surfaces ChainSchemaMismatchInterface<wire, firstStageIn>.
/**
* Transform.chain — Example 3: Pairwise stage type safety
* Demonstrates: correct stage-to-stage type flow (string → number → ISO string)
*
* Each stage's output type must match the next stage's input type. Here a
* millisecond-since-epoch string in the Bastian Balthazar Bux order fixture
* is parsed through two stages: string → number (ms) → ISO date-time string.
* The canonical form is a JSON-schema-expressible string.
*/
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 step1 = {
'decode': (epochStr: string) => {
return Number.parseInt(epochStr, 10);
},
'encode': String
} as const;
const step2 = {
'decode': (epochMs: number) => {
return new Date(epochMs).toISOString();
},
'encode': (isoString: string) => {
return String(new Date(isoString).getTime());
}
} as const;
// Correct — string → number → ISO string (pairwise types align). The stage tuple
// and terminal output type are inferred from the transforms argument.
const EpochDateSchema = Transform.chain(
{
'$id': 'https://bookstore.example/EpochDate',
'type': 'string'
} as const,
[
step1,
step2
]
);
jt.set(EpochDateSchema);
const orderMs = String(new Date(aboxFixtures.order.placedAt).getTime());
const canonical = jt.instantiate(EpochDateSchema, orderMs);
if (typeof canonical !== 'string') {
throw new TypeError('Expected string (ISO date-time) from chain decode');
}
const decodedDate = new Date(canonical);
console.assert(decodedDate.getFullYear() === 2026);
// string → number (ms) → ISO string: each stage output feeds the next stage input.
console.log('epoch ms string :', orderMs);
console.log('canonical ISO :', canonical);
The chain parameter is typed as TStages & ValidateChainType<TStages, InferSchemaType<TSchema>>. When validation fires, the intersection collapses incompatible positions to never and the user's literal stages are not assignable - the call site is rejected.
Chains are checked up to 10 stages (TupleRecursionCap).
Bad examples - what NOT to do
Anti-pattern 1: Using chain for a single transformation step
/**
* Transform.chain — Anti-pattern contrast: prefer Transform.create for one step
* Demonstrates: single decode/encode pair belongs in Transform.create, not chain
*
* The anti-pattern uses a chain with a single element. The canonical alternative
* is Transform.create. Both are shown here to confirm runtime equivalence — the
* correct form is the Transform.create version. Title fixture is Michael Ende's
* Die unendliche Geschichte (Thienemann Verlag, 1979).
*/
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();
// ✓ Correct: Transform.create for a single decode/encode pair.
const CorrectSchema = Transform.create(
{
'$id': 'https://bookstore.example/SingleStepDateCorrect',
'format': 'date-time',
'type': 'string'
} as const,
{
'decode': (isoString: string) => {
return new Date(isoString).toISOString();
},
'encode': (isoString: string) => {
return isoString;
}
}
);
jt.set(CorrectSchema);
const raw = '2026-04-12T14:23:11.000Z';
const canonical = jt.instantiate(CorrectSchema, raw);
if (typeof canonical !== 'string') {
throw new TypeError('Expected string (ISO date-time) from decode');
}
const wire = jt.encode(CorrectSchema, canonical);
console.assert(wire === raw);
// Transform.create is the correct API for a single decode/encode pair.
console.log('canonical ISO :', canonical);
console.log('re-encoded :', wire);
Comparison
Transform.chain(schema, [
{
decode: (raw: string) => raw.replace(/[$,]/g, ''),
encode: (s: string) => `$${s}`
},
{
decode: (s: string) => parseFloat(s),
encode: (n: number) => n.toFixed(2)
},
]);
// Decode runs left-to-right; encode runs right-to-left.// Zod chains transforms sequentially via .transform():
const schema = z.string()
.transform(s => s.replace(/[$,]/g, ''))
.transform(s => parseFloat(s));
// No built-in encode reversal.import * as v from 'valibot';
const schema = v.pipe(
v.string(),
v.transform((s) => s.replace(/[$,]/g, '')),
v.transform((s) => parseFloat(s)),
);
// Limitation: v.pipe is decode-direction only; no encode reversal.import * as t from 'io-ts';
// Limitation: io-ts has no built-in chaining of multiple decode/encode pairs.
// Each Type carries one decode + one encode; chain them by hand or build a
// composite codec class:
const StripCodec = new t.Type<string, string, string>(
'Strip',
t.string.is,
(input) => t.success(input.replace(/[$,]/g, '')),
(output) => `$${output}`,
);
const ParseCodec = new t.Type<number, string, string>(
'Parse',
(u): u is number => typeof u === 'number',
(input) => t.success(parseFloat(input)),
(output) => output.toFixed(2),
);
// Decode by composing manually: ParseCodec.decode(StripCodec.decode(raw).right)// TypeBox has no chaining mechanism. Apply manually after validation:
const validated = Value.Check(schema, raw);
const stripped = (raw as string).replace(/[$,]/g, '');
const price = parseFloat(stripped);
// Limitation: no schema-bound chain; encode direction must be implemented
// separately; callers must manage step ordering manually.// AJV has no chaining mechanism. Apply transformations manually after validation.
// Limitation: no schema-bound chain; encode reversal is not automatic;
// step order is the caller's responsibility.from pydantic import field_validator
class PricedItem(BaseModel):
price: float
@field_validator('price', mode='before')
@classmethod
def parse_price(cls, v):
if isinstance(v, str):
return float(v.replace('$', '').replace(',', ''))
return v
# No built-in multi-step chaining or encode reversal.// 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- single decode/encode pair (simpler)jt.encode- apply the encode chain to convert domain → wire- Serialization -
dump()appliesencodeduring schema graph traversal
See also
- Bookstore domain - where price schemas are used