Skip to content

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

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

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

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

Comparison

ts
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.
ts
// Zod chains transforms sequentially via .transform():
const schema = z.string()
  .transform(s => s.replace(/[$,]/g, ''))
  .transform(s => parseFloat(s));
// No built-in encode reversal.
ts
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.
ts
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)
ts
// 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.
ts
// 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.
py
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.
ts
// Limitation: feature not directly supported in Yup. See /comparisons for the matrix.
ts
// Limitation: feature not directly supported in Joi. See /comparisons for the matrix.
ts
// Limitation: feature not directly supported in Effect Schema. See /comparisons for the matrix.
ts
// Limitation: feature not directly supported in ArkType. See /comparisons for the matrix.
ts
// Limitation: feature not directly supported in Runtypes. See /comparisons for the matrix.

See also

Released under the MIT License.