Skip to content

Class hydration via Transform.create

Plain JSON in, real class instance out, no new API. Transform.create's decode and encode are a generic wire-to-runtime pair, and "runtime" is allowed to be a class instance with methods, getters, and an instanceof identity. This page documents the patterns that already work today.

All examples use the bookstore domain. For the underlying API see Transform.create and jt.encode.


The pattern in one block

/**
 * Class hydration — Example 1: wire-form Order → Order class instance
 *
 * `Transform.create`'s decode/encode pair is a generic wire-to-runtime
 * bridge — "runtime" is allowed to be a class with methods, getters,
 * and `instanceof` identity. The decoder turns the canonical
 * Bastian-orders-Neverending-Story fixture into an `OrderRecord`
 * class instance; the encoder round-trips back to the wire shape.
 *
 * The transform is registered on a `Compose.equivalent` of `OrderSchema`
 * (a structurally-identical sibling) rather than the canonical
 * `OrderSchema` directly, so the canonical schema's existing
 * `orderTotalMatchesItems` invariant and other examples keep their
 * plain wire-shape behaviour.
 */

import { Compose } from '../../../src/index.js';
import type { UnbrandType } from '../../../src/types/index.js';
import {
  aboxFixtures, createBookstoreDocRegistry,
  OrderSchema
} 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();

// The canonical (brand-free, structurally-widened) Order shape — what the
// transform's decode produces and encode consumes.
type OrderWire = UnbrandType<typeof aboxFixtures.order>;

class OrderRecord {
  public constructor(
    public readonly orderId: string,
    public readonly customerId: string,
    public readonly orderLines: OrderWire['orderLines'],
    public readonly orderTotal: OrderWire['orderTotal'],
    public readonly shippingAddress: OrderWire['shippingAddress'],
    public readonly placedAt: string
  ) {}

  public totalWithTax(rate = 0.19): number {
    return this.orderTotal.amount * (1 + rate);
  }
}

const OrderRecordSchema = Compose.equivalent(
  OrderSchema,
  { '$id': 'https://bookstore.example/OrderRecord' } as const
);

jt.set(OrderRecordSchema);

// Class hydration uses a normalize transform in REVERSE: the class is the wire
// side (TWire), the schema's canonical JSON is what `instantiate` validates.
//   - decode (wire → canonical): lowers an OrderRecord instance to canonical JSON.
//   - encode (canonical → wire): hydrates canonical JSON into an OrderRecord.
// So hydration is `encode`; validating a record into canonical JSON is `instantiate`.
// `addTransform` (instance-bound) resolves the canonical via the registry's refs.
const OrderRecordTransform = jt.addTransform(OrderRecordSchema, {
  'decode': (record: OrderRecord) => {
    return {
      'customerId': record.customerId,
      'orderId': record.orderId,
      'orderLines': record.orderLines,
      'orderTotal': record.orderTotal,
      'placedAt': record.placedAt,
      'shippingAddress': record.shippingAddress
    };
  },
  'encode': (wire) => {
    // The canonical Order is structurally wider than the fixture-shaped record
    // (variadic orderLines, optional address fields). Narrow once at the
    // hydration boundary — the runtime values are the validated canonical JSON.
    const source = wire as OrderWire;

    return new OrderRecord(
      source.orderId,
      source.customerId,
      source.orderLines,
      source.orderTotal,
      source.shippingAddress,
      source.placedAt
    );
  }
});

// Hydrate canonical JSON into a class instance — the encode direction.
const hydrated = jt.encode(OrderRecordTransform, aboxFixtures.order as unknown as OrderWire);

console.assert(hydrated instanceof OrderRecord);
console.assert(hydrated.totalWithTax() > aboxFixtures.order.orderTotal.amount);
// true
console.log('instanceof OrderRecord:', hydrated instanceof OrderRecord);
// amount * 1.19
console.log('totalWithTax (19%):', hydrated.totalWithTax());

// Lower a class instance back to validated canonical JSON — the decode direction,
// run by instantiate.
const wire = jt.instantiate(OrderRecordTransform, hydrated);

console.assert(typeof wire.orderId === 'string');
console.assert(Array.isArray(wire.orderLines));
// same UUID as fixture
console.log('re-encoded orderId:', wire.orderId);
Output
Press Execute to run this example against the real library.

That is the whole pattern. The remainder of this page is variations, tradeoffs, and recipes for real frameworks.


Why this works without a new API

Transform.create(schema, { decode, encode }) registers a wire-to-runtime pair on the schema. The pair is symmetric: decode runs before validation when jt.instantiate produces a value (the schema describes decode's output, so validation runs on the decoded result — see Canonical decode/default ordering), and encode runs when jt.encode (or jt.dump) projects the value back to the wire shape.

A class instance is just a runtime type that happens to carry methods on its prototype. There is nothing special about new Foo() that the registry needs to know about. Any function that takes a validated plain object and returns "the runtime thing" is a legal decode, and any function that returns the wire shape from "the runtime thing" is a legal encode.

The phantom brand carried by TransformedType<TSchema, Order> (which Transform.create returns) makes InferType<typeof OrderSchema> resolve to Order, so call sites get the class type directly without casts. Type inference, schema graph, and registry plumbing all work as they do for Date or Decimal.

In short, class hydration is not a feature; it is a use of an existing feature. Nothing new is needed.


Three lift strategies

There are three idiomatic ways to turn a validated plain object into a class instance. Each has tradeoffs.

Object.assign(Reflect.construct(Order, []), plain)

/**
 * Class hydration — `Reflect.construct(Class, [])` lift strategy
 *
 * Default strategy for parameterless constructors. Works whether or
 * not the constructor takes arguments because `Reflect.construct` is
 * called with an empty arg list. Tradeoff: the constructor runs once
 * per `encode` — if it does I/O or registers itself with a
 * parent collection, that work repeats on every hydration.
 *
 * Registered on a `Compose.equivalent` sibling of `OrderSchema` so
 * the canonical Bastian-orders-Neverending-Story scenario keeps its
 * plain wire-shape behaviour everywhere else.
 */

import type { UnbrandType } from '../../../src/types/index.js';
import {
  Compose
} from '../../../src/index.js';
import {
  aboxFixtures, createBookstoreDocRegistry,
  OrderSchema
} 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();

// The canonical (brand-free) Order shape — what the transform's encode consumes
type OrderWire = UnbrandType<typeof aboxFixtures.order>;

class OrderViaReflect {
  declare public customerId: string;
  declare public orderId: string;
  declare public orderLines: OrderWire['orderLines'];
  declare public orderTotal: OrderWire['orderTotal'];
  declare public placedAt: OrderWire['placedAt'];
  declare public shippingAddress: OrderWire['shippingAddress'];

  public status(): string {
    return `shipped:${this.orderId}`;
  }
}

const ReflectOrderSchema = Compose.equivalent(
  OrderSchema,
  { '$id': 'https://bookstore.example/ReflectOrder' } as const
);

jt.set(ReflectOrderSchema);

// Class hydration: the class is the wire side (TWire).
// - decode (class → canonical): lowers an OrderViaReflect instance to JSON.
// - encode (canonical → class): hydrates canonical JSON into an OrderViaReflect.
const ReflectOrderTransform = jt.addTransform(ReflectOrderSchema, {
  'decode': (instance: OrderViaReflect) => {
    return {
      'customerId': instance.customerId,
      'orderId': instance.orderId,
      'orderLines': instance.orderLines,
      'orderTotal': instance.orderTotal,
      'placedAt': instance.placedAt,
      'shippingAddress': instance.shippingAddress
    };
  },
  'encode': (wire) => {
    const source = wire as OrderWire;
    const hydrated = Reflect.construct(OrderViaReflect, []);

    return Object.assign(hydrated, {
      'customerId': source.customerId,
      'orderId': source.orderId,
      'orderLines': source.orderLines,
      'orderTotal': source.orderTotal,
      'placedAt': source.placedAt,
      'shippingAddress': source.shippingAddress
    });
  }
});

// Hydrate canonical JSON into a class instance via encode.
const hydrated = jt.encode(ReflectOrderTransform, aboxFixtures.order as unknown as OrderWire);

console.assert(hydrated instanceof OrderViaReflect);
console.assert(hydrated.status().startsWith('shipped:'));
// true
console.log('instanceof:', hydrated instanceof OrderViaReflect);
// 'shipped:<orderId>'
console.log('status():', hydrated.status());
Output
Press Execute to run this example against the real library.

When to use. Default for most cases. Works whether or not the constructor takes arguments, because Reflect.construct(Order, []) calls it with [].

Gotcha. The constructor still runs once per instantiate. If it logs, registers itself with a parent collection, opens a file handle, or otherwise does I/O, every hydrated instance will repeat that work.

Object.assign(new Order(), plain)

/**
 * Class hydration — `Object.assign(new Order(), plain)` lift strategy
 *
 * Same case as `Reflect.construct` but with the familiar `new` syntax.
 * Only valid when the constructor is parameterless or all parameters
 * are optional. Same constructor side-effect concern as 19; fails at
 * compile time if the constructor signature requires arguments.
 *
 * Registered on a `Compose.equivalent` sibling of `OrderSchema`.
 */

import type { UnbrandType } from '../../../src/types/index.js';
import {
  Compose
} from '../../../src/index.js';
import {
  aboxFixtures, createBookstoreDocRegistry,
  OrderSchema
} 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();

// The canonical (brand-free) Order shape
type OrderWire = UnbrandType<typeof aboxFixtures.order>;

class OrderViaNew {
  declare public customerId: string;
  declare public orderId: string;
  declare public orderLines: OrderWire['orderLines'];
  declare public orderTotal: OrderWire['orderTotal'];
  declare public placedAt: OrderWire['placedAt'];
  declare public shippingAddress: OrderWire['shippingAddress'];

  public summary(): string {
    return `order ${this.orderId}`;
  }
}

const NewOrderSchema = Compose.equivalent(
  OrderSchema,
  { '$id': 'https://bookstore.example/NewOrder' } as const
);

jt.set(NewOrderSchema);

// Class is the wire side: decode lowers to JSON, encode hydrates from JSON.
const NewOrderTransform = jt.addTransform(NewOrderSchema, {
  'decode': (instance: OrderViaNew) => {
    return {
      'customerId': instance.customerId,
      'orderId': instance.orderId,
      'orderLines': instance.orderLines,
      'orderTotal': instance.orderTotal,
      'placedAt': instance.placedAt,
      'shippingAddress': instance.shippingAddress
    };
  },
  'encode': (wire) => {
    const source = wire as OrderWire;

    return Object.assign(new OrderViaNew(), {
      'customerId': source.customerId,
      'orderId': source.orderId,
      'orderLines': source.orderLines,
      'orderTotal': source.orderTotal,
      'placedAt': source.placedAt,
      'shippingAddress': source.shippingAddress
    });
  }
});

// Hydrate canonical JSON via encode.
const hydrated = jt.encode(NewOrderTransform, aboxFixtures.order as unknown as OrderWire);

console.assert(hydrated instanceof OrderViaNew);
console.assert(hydrated.summary().startsWith('order '));
// true
console.log('instanceof:', hydrated instanceof OrderViaNew);
// 'order <orderId>'
console.log('summary():', hydrated.summary());
Output
Press Execute to run this example against the real library.

When to use. Same case as Reflect.construct, but the syntax is more familiar. Only valid when the constructor is parameterless or all parameters are optional.

Gotcha. Same constructor side-effect concern. Also fails at compile time if the constructor signature requires arguments.

Order.fromPlain(plain)

/**
 * Class hydration — `Order.fromPlain(plain)` lift strategy
 *
 * Recommended for classes with `#privateFields`, non-trivial
 * constructors, derived state, or invariants the class needs to
 * enforce on construction. The class stays in control of its own
 * initialization at the cost of a few extra lines per entity.
 *
 * Registered on a `Compose.equivalent` sibling of `OrderSchema` so
 * the canonical scenario keeps its plain wire shape.
 */

import type { UnbrandType } from '../../../src/types/index.js';
import {
  Compose
} from '../../../src/index.js';
import {
  aboxFixtures, createBookstoreDocRegistry,
  OrderSchema
} 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();

// The canonical (brand-free) Order shape
type OrderWire = UnbrandType<typeof aboxFixtures.order>;

class OrderViaFromPlain {
  public static fromPlain(plain: OrderWire): OrderViaFromPlain {
    const built = new OrderViaFromPlain(plain.orderId, plain.customerId);

    built.orderLines = plain.orderLines;
    built.orderTotal = plain.orderTotal;
    built.shippingAddress = plain.shippingAddress;
    built.placedAt = plain.placedAt;

    return built;
  }

  declare public orderLines: OrderWire['orderLines'];
  declare public orderTotal: OrderWire['orderTotal'];
  declare public placedAt: OrderWire['placedAt'];
  declare public shippingAddress: OrderWire['shippingAddress'];

  public constructor(
    public readonly orderId: string,
    public readonly customerId: string
  ) {}

  public toPlain(): {
    readonly 'customerId': string;
    readonly 'orderId': string;
    readonly 'orderLines': OrderWire['orderLines'];
    readonly 'orderTotal': OrderWire['orderTotal'];
    readonly 'placedAt': OrderWire['placedAt'];
    readonly 'shippingAddress': OrderWire['shippingAddress'];
  } {
    return {
      'customerId': this.customerId,
      'orderId': this.orderId,
      'orderLines': this.orderLines,
      'orderTotal': this.orderTotal,
      'placedAt': this.placedAt,
      'shippingAddress': this.shippingAddress
    };
  }
}

const FromPlainOrderSchema = Compose.equivalent(
  OrderSchema,
  { '$id': 'https://bookstore.example/FromPlainOrder' } as const
);

jt.set(FromPlainOrderSchema);

// Class is the wire side: encode calls fromPlain to hydrate, decode calls toPlain to lower.
const FromPlainOrderTransform = jt.addTransform(FromPlainOrderSchema, {
  'decode': (instance: OrderViaFromPlain) => {
    return instance.toPlain();
  },
  'encode': (wire) => {
    const source = wire as OrderWire;

    return OrderViaFromPlain.fromPlain(source);
  }
});

// Hydrate canonical JSON via encode.
const hydrated = jt.encode(FromPlainOrderTransform, aboxFixtures.order as unknown as OrderWire);

console.assert(hydrated instanceof OrderViaFromPlain);
console.assert(hydrated.orderId === aboxFixtures.order.orderId);
console.assert(hydrated.customerId === aboxFixtures.order.customerId);
// true
console.log('instanceof:', hydrated instanceof OrderViaFromPlain);
// same orderId as fixture
console.log('orderId:', hydrated.orderId);
// true
console.log('round-trip:', hydrated.toPlain().orderId === aboxFixtures.order.orderId);
Output
Press Execute to run this example against the real library.

When to use. Recommended for classes with #privateFields, non-trivial constructors, derived state, or invariants the class needs to enforce on construction.

Gotcha. You write more code per class. The payoff is that hydration is explicit and the class stays in control of its own initialization.

Object.setPrototypeOf(plain, Order.prototype)

/**
 * Class hydration — `Object.setPrototypeOf(plain, Order.prototype)`
 *
 * Hot-path lift strategy: skips the constructor entirely and reuses
 * the validated wire object as the instance backing store. Allocation
 * cost: zero. Tradeoff: private (`#`) fields are not initialized, so
 * any access throws `TypeError: Cannot read private member from an
 * object whose class did not declare it`. Use only when the class
 * has no `#` fields and the constructor has no required work.
 */

import type { UnbrandType } from '../../../src/types/index.js';
import {
  Compose
} from '../../../src/index.js';
import {
  aboxFixtures, createBookstoreDocRegistry,
  OrderSchema
} 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();

// The canonical (brand-free) Order shape
type OrderWire = UnbrandType<typeof aboxFixtures.order>;

class OrderViaProto {
  declare public customerId: string;
  declare public orderId: string;
  declare public orderLines: OrderWire['orderLines'];
  declare public orderTotal: OrderWire['orderTotal'];
  declare public placedAt: OrderWire['placedAt'];
  declare public shippingAddress: OrderWire['shippingAddress'];

  public lineCount(): number {
    return this.orderLines.length;
  }
}

const ProtoOrderSchema = Compose.equivalent(
  OrderSchema,
  { '$id': 'https://bookstore.example/ProtoOrder' } as const
);

jt.set(ProtoOrderSchema);

// Class is the wire side: encode swaps prototype in place, decode spreads to plain object.
const ProtoOrderTransform = jt.addTransform(ProtoOrderSchema, {
  'decode': (instance: OrderViaProto) => {
    const {
      customerId, orderId, orderLines, orderTotal, placedAt, shippingAddress
    } = instance;

    return {
      customerId,
      orderId,
      orderLines,
      orderTotal,
      placedAt,
      shippingAddress
    };
  },
  'encode': (wire) => {
    const source = wire as OrderWire;
    const copy = {
      'customerId': source.customerId,
      'orderId': source.orderId,
      'orderLines': source.orderLines,
      'orderTotal': source.orderTotal,
      'placedAt': source.placedAt,
      'shippingAddress': source.shippingAddress
    };

    Object.setPrototypeOf(copy, OrderViaProto.prototype);

    return copy as OrderViaProto;
  }
});

// Hydrate canonical JSON via encode.
const hydrated = jt.encode(ProtoOrderTransform, { ...aboxFixtures.order } as unknown as OrderWire);

console.assert(hydrated instanceof OrderViaProto);
console.assert(hydrated.lineCount() === aboxFixtures.order.orderLines.length);
// true — prototype swapped in place
console.log('instanceof:', hydrated instanceof OrderViaProto);
// same as orderLines.length
console.log('lineCount():', hydrated.lineCount());
Output
Press Execute to run this example against the real library.

When to use. Hot paths where allocation is the bottleneck. Skips the constructor entirely; reuses the validated object as the instance backing store.

Gotcha. Private fields (#name) are bound during class initialization. A prototype swap does not initialize them, so any access throws TypeError: Cannot read private member from an object whose class did not declare it. If your class uses #, do not use this strategy.


The encode direction

encode must take a hydrated instance back to the validated wire shape. Three idiomatic options:

Filter methods automatically

/**
 * Class hydration — decode direction: filter methods automatically
 *
 * Default in the headline example: filter out non-data values via
 * `Object.entries`. Works because prototype methods are not
 * enumerable own-properties; the filter is mostly belt-and-suspenders
 * unless the class assigns methods as instance fields (`this.foo =
 * () => ...`), in which case the filter is the only thing that
 * keeps the wire shape clean when lowering back to JSON.
 */

import type { UnbrandType } from '../../../src/types/index.js';
import {
  Compose
} from '../../../src/index.js';
import {
  aboxFixtures, createBookstoreDocRegistry,
  OrderSchema
} 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();

// The canonical (brand-free) Order shape
type OrderWire = UnbrandType<typeof aboxFixtures.order>;

class OrderWithInstanceMethod {
  declare public customerId: string;
  declare public orderId: string;
  declare public orderLines: OrderWire['orderLines'];
  declare public orderTotal: OrderWire['orderTotal'];
  declare public placedAt: OrderWire['placedAt'];
  declare public shippingAddress: OrderWire['shippingAddress'];
  // Instance-field method (enumerable own-property). The filter
  // in decode below is what drops this when lowering to wire.
  public summarize = (): string => {
    return `order ${this.orderId}`;
  };
}

const FilterEncodeOrderSchema = Compose.equivalent(
  OrderSchema,
  { '$id': 'https://bookstore.example/FilterEncodeOrder' } as const
);

jt.set(FilterEncodeOrderSchema);

// Class is the wire side: decode filters out methods, encode hydrates from JSON.
const FilterEncodeOrderTransform = jt.addTransform(
  FilterEncodeOrderSchema,
  {
    'decode': (instance: OrderWithInstanceMethod) => {
      const {
        customerId, orderId, orderLines, orderTotal, placedAt, shippingAddress
      } = instance;

      return {
        customerId,
        orderId,
        orderLines,
        orderTotal,
        placedAt,
        shippingAddress
      };
    },
    'encode': (wire) => {
      const source = wire as OrderWire;

      return Object.assign(Reflect.construct(OrderWithInstanceMethod, []), source);
    }
  }
);

// Hydrate canonical JSON via encode — the instance will have the summarize method.
const hydrated = jt.encode(
  FilterEncodeOrderTransform,
  aboxFixtures.order as unknown as OrderWire
);

console.assert(typeof hydrated.summarize === 'function');

// The decode direction filters out instance-field methods when lowering to canonical JSON.
// Demonstrate by creating an instance with the method and manually calling decode logic:
const instanceWithMethod: OrderWithInstanceMethod = Object.assign(
  Reflect.construct(OrderWithInstanceMethod, []),
  aboxFixtures.order
);

// Manually apply the filter (simulating what decode does):
const dataEntries = Object.entries(instanceWithMethod).filter(([
  , value
]) => {
  return typeof value !== 'function';
});
const filtered = Object.fromEntries(dataEntries);

console.assert(filtered.summarize === undefined);
console.assert(filtered.orderId === aboxFixtures.order.orderId);
// 'function'
console.log('instance has summarize fn:', typeof hydrated.summarize);
// false — filtered out by decoder
console.log('filtered has summarize?', 'summarize' in filtered);
// clean wire value
console.log('filtered orderId:', filtered.orderId);
Output
Press Execute to run this example against the real library.

This is the default in the headline example. It works because prototype methods are not enumerable own-properties: Object.entries(instance) only sees the data assigned by decode, so the filter does its real work when the class assigns methods as instance fields (this.foo = () => ...).

instance.toJSON()

/**
 * Class hydration — decode direction: `instance.toJSON()`
 *
 * The class already defines `toJSON` for `JSON.stringify` integration;
 * the decode body reuses it so there is one source of truth for
 * lowering shape when converting back to canonical JSON.
 */

import type { UnbrandType } from '../../../src/types/index.js';
import {
  Compose
} from '../../../src/index.js';
import {
  aboxFixtures, createBookstoreDocRegistry,
  OrderSchema
} 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();

// The canonical (brand-free) Order shape
type OrderWire = UnbrandType<typeof aboxFixtures.order>;

class OrderWithToJson {
  declare public customerId: string;
  declare public orderId: string;
  declare public orderLines: OrderWire['orderLines'];
  declare public orderTotal: OrderWire['orderTotal'];
  declare public placedAt: OrderWire['placedAt'];
  declare public shippingAddress: OrderWire['shippingAddress'];

  public toJSON(): {
    readonly 'customerId': string;
    readonly 'orderId': string;
    readonly 'orderLines': OrderWire['orderLines'];
    readonly 'orderTotal': OrderWire['orderTotal'];
    readonly 'placedAt': OrderWire['placedAt'];
    readonly 'shippingAddress': OrderWire['shippingAddress'];
  } {
    return {
      'customerId': this.customerId,
      'orderId': this.orderId,
      'orderLines': this.orderLines,
      'orderTotal': this.orderTotal,
      'placedAt': this.placedAt,
      'shippingAddress': this.shippingAddress
    };
  }
}

const ToJsonOrderSchema = Compose.equivalent(
  OrderSchema,
  { '$id': 'https://bookstore.example/ToJsonOrder' } as const
);

jt.set(ToJsonOrderSchema);

// Class is the wire side: decode uses toJSON, encode hydrates from JSON.
const ToJsonOrderTransform = jt.addTransform(ToJsonOrderSchema, {
  'decode': (instance: OrderWithToJson) => {
    return instance.toJSON();
  },
  'encode': (wire) => {
    const source = wire as OrderWire;

    return Object.assign(Reflect.construct(OrderWithToJson, []), source);
  }
});

// Hydrate canonical JSON via encode.
const hydrated = jt.encode(
  ToJsonOrderTransform,
  aboxFixtures.order as unknown as OrderWire
);

// The instance has toJSON; decode will call it when lowering back to JSON.
// Demonstrate by calling toJSON directly on the hydrated instance:
const viaTojson = hydrated.toJSON();

console.assert(viaTojson.orderId === aboxFixtures.order.orderId);
// JSON.stringify will use the same toJSON shape.
const cloned: unknown = structuredClone(viaTojson);

console.assert(typeof cloned === 'object');
// same as fixture — toJSON() is what decode uses
console.log('viaTojson orderId:', viaTojson.orderId);
// 'object' — structuredClone works on toJSON output
console.log('cloned type:', typeof cloned);
Output
Press Execute to run this example against the real library.

When to use. The class already defines toJSON for JSON.stringify integration. Reusing it as the encode body keeps one source of truth for serialization shape.

Explicit instance.toPlain()

/**
 * Class hydration — decode direction: explicit `instance.toPlain()`
 *
 * The class needs to omit derived fields, hide private state, or
 * apply transforms before lowering to canonical JSON. `toPlain()` is a useful
 * convention when `toJSON` is reserved for a different output
 * format (e.g. an external API representation).
 */

import type { UnbrandType } from '../../../src/types/index.js';
import {
  Compose
} from '../../../src/index.js';
import {
  aboxFixtures, createBookstoreDocRegistry,
  OrderSchema
} 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();

// The canonical (brand-free) Order shape
type OrderWire = UnbrandType<typeof aboxFixtures.order>;

class OrderWithToPlain {
  // Private state intentionally omitted from the wire shape.
  #internalCacheKey = '';

  declare public customerId: string;
  declare public orderId: string;
  declare public orderLines: OrderWire['orderLines'];
  declare public orderTotal: OrderWire['orderTotal'];
  declare public placedAt: OrderWire['placedAt'];
  declare public shippingAddress: OrderWire['shippingAddress'];

  public cacheTouch(): void {
    this.#internalCacheKey = String(Date.now());
  }

  public cacheTouched(): boolean {
    return this.#internalCacheKey.length > 0;
  }

  public toPlain(): {
    readonly 'customerId': string;
    readonly 'orderId': string;
    readonly 'orderLines': OrderWire['orderLines'];
    readonly 'orderTotal': OrderWire['orderTotal'];
    readonly 'placedAt': OrderWire['placedAt'];
    readonly 'shippingAddress': OrderWire['shippingAddress'];
  } {
    return {
      'customerId': this.customerId,
      'orderId': this.orderId,
      'orderLines': this.orderLines,
      'orderTotal': this.orderTotal,
      'placedAt': this.placedAt,
      'shippingAddress': this.shippingAddress
    };
  }
}

const ToPlainOrderSchema = Compose.equivalent(
  OrderSchema,
  { '$id': 'https://bookstore.example/ToPlainOrder' } as const
);

jt.set(ToPlainOrderSchema);

// Class is the wire side: decode uses toPlain, encode hydrates from JSON.
const ToPlainOrderTransform = jt.addTransform(ToPlainOrderSchema, {
  'decode': (instance: OrderWithToPlain) => {
    return instance.toPlain();
  },
  'encode': (wire) => {
    const source = wire as OrderWire;
    // real `new` keeps the # field initialized.
    const built = new OrderWithToPlain();

    return Object.assign(built, source);
  }
});

// Hydrate canonical JSON via encode.
const hydrated = jt.encode(
  ToPlainOrderTransform,
  aboxFixtures.order as unknown as OrderWire
);

hydrated.cacheTouch();
console.assert(hydrated.cacheTouched());

// The instance has toPlain; decode will call it when lowering back to JSON.
// Demonstrate by calling toPlain directly on the hydrated instance:
const viaToPlain = hydrated.toPlain();

console.assert(viaToPlain.orderId === aboxFixtures.order.orderId);
// internalCacheKey is deliberately omitted from the wire shape.
console.assert(!('internalCacheKey' in viaToPlain));
// true — # field lives in the instance
console.log('cacheTouched:', hydrated.cacheTouched());
// present — toPlain() includes it
console.log('viaToPlain orderId:', viaToPlain.orderId);
// false — omitted by toPlain()
console.log('viaToPlain has private key?', 'internalCacheKey' in viaToPlain);
Output
Press Execute to run this example against the real library.

When to use. The class needs to omit derived fields, hide private state, or apply transforms before serialization. toPlain is also a useful convention when the class's toJSON is reserved for a different output format (e.g. an external API representation).


Round-trip property test pattern

Class hydration is correct only if encode(instantiate(s, x)) deep-equals x. Validate that with an assert:

/**
 * Class hydration — round-trip property test pattern
 *
 * Class hydration is correct only if `decode(encode(wire))` deep-equals
 * `wire`. Validate that with an assert. The pair of `decode` and
 * `encode` are independent functions; nothing forces them to be
 * inverses, so a round-trip test catches drift the moment it
 * happens — before it propagates into queue payloads, database
 * rows, or HTTP responses.
 */

import type { UnbrandType } from '../../../src/types/index.js';
import {
  Compose
} from '../../../src/index.js';
import {
  aboxFixtures, createBookstoreDocRegistry,
  OrderSchema
} from '../bookstore/index.js';

// Browser-safe strict assertions (same shape as node:assert's strict mode),
// so this round-trip test runs anywhere, not just under Node.
const assert = {
  deepStrictEqual(actual: unknown, expected: unknown, message?: string): void {
    if (JSON.stringify(actual) !== JSON.stringify(expected)) {
      throw new Error(message ?? 'values are not deep-equal');
    }
  },
  ok(value: boolean, message?: string): void {
    if (!value) {
      throw new Error(message ?? 'expected a truthy value');
    }
  }
};

// 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();

// The canonical (brand-free) Order shape
type OrderWire = UnbrandType<typeof aboxFixtures.order>;

class RoundTripOrder {
  declare public customerId: string;
  declare public orderId: string;
  declare public orderLines: OrderWire['orderLines'];
  declare public orderTotal: OrderWire['orderTotal'];
  declare public placedAt: OrderWire['placedAt'];
  declare public shippingAddress: OrderWire['shippingAddress'];
}

const RoundTripOrderSchema = Compose.equivalent(
  OrderSchema,
  { '$id': 'https://bookstore.example/RoundTripOrder' } as const
);

jt.set(RoundTripOrderSchema);

// Class is the wire side: encode hydrates, decode lowers.
const RoundTripOrderTransform = jt.addTransform(RoundTripOrderSchema, {
  'decode': (instance: RoundTripOrder) => {
    return { ...instance };
  },
  'encode': (wire) => {
    const source = wire as OrderWire;

    return Object.assign(Reflect.construct(RoundTripOrder, []), source);
  }
});

const wire = aboxFixtures.order as unknown as OrderWire;
// Hydrate canonical JSON via encode.
const instance = jt.encode(RoundTripOrderTransform, wire);

assert.ok(instance instanceof RoundTripOrder);

// Lower the instance back to canonical JSON via instantiate (which calls decode).
const reLowered = jt.instantiate(RoundTripOrderTransform, instance);

assert.deepStrictEqual(reLowered, wire);

console.log('hydrated instance is RoundTripOrder:', instance instanceof RoundTripOrder);
console.log('round-trip instantiate(encode(wire)) deep-equals wire:', JSON.stringify(reLowered) === JSON.stringify(wire));
Output
Press Execute to run this example against the real library.

This matters because decode and encode are independent functions; nothing forces them to be inverses. A round-trip test is the cheapest way to catch drift the moment it happens, before it propagates into queue payloads, database rows, or HTTP responses.


ORM-specific recipes

For TypeORM, Prisma, and Sequelize patterns, see Class hydration: ORM recipes.


Nested class hydration

When one class-attached schema $refs another class-attached schema, the registry walks references and applies each schema's decoder bottom-up.

/**
 * Class hydration — composing multiple class transforms
 *
 * When you want to hydrate nested data structures with multiple
 * class types, encode each class separately using its transform,
 * then compose them. Each class is the wire side of its schema:
 * the class defines the interface, decode lowers it to JSON, and
 * encode lifts JSON back to the class.
 *
 * This pattern works when the transforms are registered on
 * `Compose.equivalent` siblings so the canonical schemas keep
 * their plain wire-shape behaviour elsewhere.
 */

import type { UnbrandType } from '../../../src/types/index.js';
import {
  Compose
} from '../../../src/index.js';
import {
  aboxFixtures, createBookstoreDocRegistry,
  CustomerSchema
} 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();

// The canonical (brand-free) Customer shape
type CustomerWire = UnbrandType<typeof aboxFixtures.customer>;

class CustomerRecord {
  declare public addresses: CustomerWire['addresses'];
  declare public customerId: CustomerWire['customerId'];
  declare public email: CustomerWire['email'];
  declare public name: CustomerWire['name'];

  public greet(): string {
    return `hello ${this.name}`;
  }
}

const CustomerRecordSchema = Compose.equivalent(
  CustomerSchema,
  { '$id': 'https://bookstore.example/CustomerRecord' } as const
);

jt.set(CustomerRecordSchema);

// Class is the wire side: encode hydrates CustomerRecord, decode lowers it.
const CustomerRecordTransform = jt.addTransform(CustomerRecordSchema, {
  'decode': (instance: CustomerRecord) => {
    return {
      'addresses': instance.addresses,
      'customerId': instance.customerId,
      'email': instance.email,
      'name': instance.name
    };
  },
  'encode': (wire) => {
    const source = wire as CustomerWire;

    return Object.assign(Reflect.construct(CustomerRecord, []), {
      'addresses': source.addresses,
      'customerId': source.customerId,
      'email': source.email,
      'name': source.name
    });
  }
});

// Compose multiple class transforms by encoding each separately:
// First, hydrate the buyer using its own transform.
const customerWire = aboxFixtures.customer as unknown as CustomerWire;
const hydratedBuyer = jt.encode(CustomerRecordTransform, customerWire);

console.assert(hydratedBuyer instanceof CustomerRecord);
console.assert(hydratedBuyer.greet() === `hello ${aboxFixtures.customer.name}`);
// true
console.log('buyer instanceof CustomerRecord:', hydratedBuyer instanceof CustomerRecord);
// true
console.log('buyer is properly hydrated:', typeof hydratedBuyer.greet === 'function');
// 'hello Bastian Balthazar Bux'
console.log('buyer.greet():', hydratedBuyer.greet());
Output
Press Execute to run this example against the real library.

When jt.instantiate(OrderSchema.$id, raw) runs, the registry first decodes raw.buyer through CustomerSchema's decoder (producing a Customer instance), then runs OrderSchema's decoder over the now-mixed plain-object/Customer payload. The result: order.buyer is a Customer and order is an Order. order.buyer.greet() is callable directly.

encode runs in the reverse direction: Order.encode projects the Order to a plain shape that still contains a Customer in buyer, and the registry then applies Customer.encode to that field on its way back to the wire.


Comparison with other libraries

ts
class User { greet(): string { return `hi ${this.name}`; } name!: string; }

const UserSchema = Transform.create(
  { $id: 'urn:User', type: 'object', properties: { name: { type: 'string' } }, required: ['name'] } as const,
  {
    decode: (plain) => Object.assign(Reflect.construct(User, []), plain),
    encode: (instance) => ({ ...instance })
  }
);

const user = jt.instantiate(UserSchema.$id, { name: 'Ada' });
user.greet();             // 'hi Ada'
user instanceof User;      // true
py
from pydantic import BaseModel

class User(BaseModel):
    name: str
    def greet(self) -> str:
        return f'hi {self.name}'

# The class IS the schema. Native class support; no separate transform call.
user = User.model_validate({'name': 'Ada'})
user.greet()
ts
import { Schema } from '@effect/schema';

class User extends Schema.Class<User>('User')({
  name: Schema.String
}) {
  greet(): string { return `hi ${this.name}`; }
}

// Native class support via Schema.Class.
const user = Schema.decodeUnknownSync(User)({ name: 'Ada' });
user.greet();
ts
import { z } from 'zod';

class User { constructor(public name: string) {} greet(): string { return `hi ${this.name}`; } }

const UserSchema = z.object({ name: z.string() }).transform((data) => new User(data.name));

const user = UserSchema.parse({ name: 'Ada' });
user.greet();
// Limitation: encode direction is not registered. The reverse must be hand-written.
ts
import { Type, Value } from '@sinclair/typebox';

const UserT = Type.Object({ name: Type.String() });
class User { constructor(public name: string) {} greet(): string { return `hi ${this.name}`; } }

// Validate, then construct manually:
const decoded = Value.Decode(UserT, { name: 'Ada' });
const user = new User(decoded.name);
// Limitation: hydration is not schema-registered; every call site must repeat it.
ts
import * as t from 'io-ts';
import { fold } from 'fp-ts/Either';

class User { constructor(public name: string) {} greet(): string { return `hi ${this.name}`; } }

const UserCodec = new t.Type<User, { name: string }>(
  'User',
  (u): u is User => u instanceof User,
  (input, ctx) => {
    const ok = t.type({ name: t.string }).decode(input);
    return fold(
      (errors) => t.failure(input, ctx, 'invalid'),
      (parsed: { name: string }) => t.success(new User(parsed.name))
    )(ok);
  },
  (user) => ({ name: user.name })
);
// Native both-direction codec, but the boilerplate is per-class and verbose.
ts
import * as v from 'valibot';

class User { constructor(public name: string) {} greet(): string { return `hi ${this.name}`; } }

const UserSchema = v.pipe(
  v.object({ name: v.string() }),
  v.transform((p) => new User(p.name))
);

const user = v.parse(UserSchema, { name: 'Ada' });
// Limitation: encode direction is not schema-registered.

Native class support: Pydantic and Effect Schema; the class declaration is the schema declaration.

Manual transform required: Zod, Valibot, TypeBox, io-ts, json-tology. The difference is that json-tology's Transform.create is the canonical pattern documented here, with first-class type inference (InferType<typeof OrderSchema> resolves to Order), automatic registry integration during $ref walks, and a registered encode direction for round-trips. The other "manual transform" libraries either lack the encode side (Zod, Valibot, TypeBox) or require per-class boilerplate (io-ts).


Caveats

  • Constructor side-effects fire on every instantiate if you use the Reflect.construct or new Order() strategies. If the constructor logs, registers itself with a parent collection, or performs I/O, prefer Order.fromPlain so you control when those effects run.
  • Private (#) fields: only Order.fromPlain (or any path that goes through new Order(...) for real) initializes them. Object.setPrototypeOf skips initialization, so the first access throws TypeError: Cannot read private member from an object whose class did not declare it.
  • instanceof checks: all four strategies set the prototype correctly, so result instanceof Order works regardless of which strategy you pick.
  • Round-trip discipline: encode must be a true inverse of decode. If decode adds derived state (this.totalWithTax = ...), encode must drop it. Use the property test pattern above to catch drift.
  • Method enumeration: the default Object.fromEntries(Object.entries(...).filter(([_, v]) => typeof v !== 'function')) works for prototype methods, because prototype methods are not enumerable own-properties. It fails if the class assigns methods as instance fields (this.markShipped = () => {...} in the constructor), because those become enumerable own-properties. The workaround is to define toJSON or toPlain on the class and use that as the encode body.
  • Schema must be registered before instantiation. As with every transform, Transform.create must run before the schema is registered with JsonTology.create({ schemas }), not after. Attaching to an already-registered reference is a no-op for the registry.

See also

Released under the MIT License.