instantiate vs materialize
Both methods validate and return a typed value. The difference is where the data came from and whether registered Transform decoders run.
Decision table
| Question | Answer | Use |
|---|---|---|
| Did this data arrive from outside your process? | Yes — HTTP body, queue message, config file, IPC payload | instantiate |
| Did you produce this data yourself? | Yes — test fixture, form scaffold, factory method | materialize |
Does the schema have a registered Transform decoder? | Yes, and the data needs decoding | instantiate |
| Do you want defaults filled without running decoders? | Yes | materialize |
| Is the data untrusted? | Yes | instantiate |
What each method does
instantiate(schema, data) — the wire-decode entry point
instantiate is the trust boundary method. Call it whenever data crosses into your system from outside.
It does four things in order:
- Deep-clones the input (the original is never mutated).
- Runs the schema's registered
Transformdecoder on the raw, cloned input. - Resolves
$refs recursively via the registeredRefDecoder, running each nested schema's decoder in turn. - Validates the fully-decoded result against the schema — filling
defaultvalues and stripping unknown properties in the same pass; throwsInstantiationErroron failure.
The returned value is the decoded, branded runtime type — Customer, not { id: string, email: string, … }.
Canonical ordering:
decode → validate (fills defaults, strips unknown) → invariants. See Canonical decode/default ordering below.
import { InstantiationError } from 'json-tology';
import { bookstoreEntities } from './bookstore/index.js';
import { CustomerSchema } from './bookstore/entities/Customer.js';
// HTTP request handler — data came from outside, use instantiate
async function handleCreateOrder(rawBody: unknown) {
const customer = bookstoreEntities.instantiate(CustomerSchema.$id, rawBody);
// customer is typed as Customer
// Transform decoders ran (e.g. a customerId UUID decoder)
// unknown properties stripped, defaults filled
}materialize(schema, partial?) — defaults and scaffolding without decode overhead
materialize is the construction helper. Call it when you produce the data yourself and want schema defaults filled in.
It does three things:
- Merges the optional partial input with the schema's declared
defaultvalues. - Validates the merged result; throws
MaterializationErroron failure (pass{ enablePartial: true }to allow missing required fields during lenient construction). - Returns the merged, validated value.
materialize does not run Transform decoders. The data is already in its final runtime shape — no decode step is needed or expected.
import { bookstoreEntities } from './bookstore/index.js';
import { BookSchema } from './bookstore/entities/Book.js';
// Test fixture — data you produced, use materialize
const fixture = bookstoreEntities.materialize(BookSchema, {
isbn: '9781234567890',
title: 'Effective Schemas',
authors: ['A. Studnicky'],
price: { amount: 39.99, currency: 'USD' },
});
// currency filled from schema default if omitted
// NO Transform decoders run — this is already your dataTransforms: why they matter for the choice
A Transform registered on a schema pairs a decoder and an encoder:
import { Transform } from 'json-tology';
import { CustomerIdSchema } from './bookstore/entities/CustomerId.js';
// The decoder runs at instantiate time, not at materialize time
Transform.create(CustomerIdSchema, {
decode: (raw) => raw.trim().toLowerCase(),
encode: (value) => value,
});When instantiate runs, every $ref-resolved property that has a registered Transform decoder runs its decode function before validation and default-filling happen. This is how wire data (raw strings, dates as ISO strings, CURIEs) becomes your domain type.
materialize skips this step entirely — the data you pass in is already your domain shape. Calling materialize on wire data where a decoder is registered means the decoder never runs and the returned value is the undecoded wire form, not the domain type.
State plainly: decode boundary data with instantiate; use materialize for fixtures and form scaffolding where transform overhead is not needed and the data is already correct.
Common misuse pattern
// WRONG — materialize skips Transform decoders
// If CustomerId has a registered decoder, it never runs here
const customer = bookstoreEntities.materialize(CustomerSchema, wireBody);
// CORRECT — instantiate runs decoders, validates, strips unknowns
const customer = bookstoreEntities.instantiate(CustomerSchema.$id, wireBody);The instinct to reach for materialize on untrusted input is the most common source of "my decoder isn't running" reports. If the data came from outside, use instantiate.
enablePartial on materialize
For forms or partial construction where some required fields are legitimately absent:
const draft = bookstoreEntities.materialize(BookSchema, {
isbn: '9781234567890',
}, { enablePartial: true });
// validates what's present, fills what has defaults, skips required-without-default fieldsThis option has no equivalent on instantiate — every required field must be present in untrusted input.
Canonical decode/default ordering
instantiate runs decode before validation, not after: decode → validate (fills defaults, strips unknown) → invariants. A decode function receives the raw wire value, not the defaulted canonical value — any default declared on the schema is filled in by the validation pass that runs after decode returns.
A passthrough decode — one that returns its input unchanged — makes the ordering directly observable: the decoder does no default-filling itself, yet instantiate's final return value has every default filled in.
/**
* Canonical decode/default ordering — passthrough decode.
*
* `instantiate` runs `decode` BEFORE validation, not after:
* `decode → validate (fills defaults, strips unknown) → invariants`.
*
* A passthrough `decode` — one that returns its input unchanged — proves
* the ordering directly: the decoder itself does no default-filling, yet
* the value `instantiate` finally returns has every default filled in,
* because the validation pass that runs after `decode` fills them.
*/
import {
JsonTology, Transform
} from '../../../src/index.js';
const ConfigSchema = {
'$id': 'urn:example:PassthroughConfig',
'properties': {
'model': { 'type': 'string' },
'port': {
'default': 11_434,
'type': 'integer'
}
},
'required': ['model'],
'type': 'object'
} as const;
// Passthrough decode: returns the raw wire value unchanged. It does not
// fill `port` itself — instantiate's validation pass does that after decode
// runs.
const ConfigCodec = Transform.create(ConfigSchema, {
'decode': (raw: { 'model': string;
'port'?: number; }) => {
return raw;
},
'encode': (value: { 'model': string;
'port'?: number; }) => {
return value;
}
});
const jt = JsonTology.create({
'baseIri': 'urn:example',
'schemas': [ConfigCodec]
});
const out = jt.instantiate(ConfigCodec.$id, { 'model': 'ollama:llama3' });
console.assert(out.model === 'ollama:llama3');
console.assert(out.port === 11_434, 'default filled in after the passthrough decode ran');
console.log('decode returned:', { 'model': 'ollama:llama3' });
console.log('instantiate returned (defaults filled after decode):', out);
This is also documented on Transform.create itself (src/modules/transform/Transform.ts): "decode consumes the raw wire type and produces the canonical value; the schema describes decode's OUTPUT, so validation runs on the decoded result (decode → validate → strip)."
Summary
instantiate | materialize | |
|---|---|---|
| Data origin | Outside (untrusted) | Inside (you produced it) |
| Runs Transform decoders | Yes | No |
Resolves $ref decoders | Yes | No |
Fills default values | Yes | Yes |
| Strips unknown properties | Yes | No |
| Partial construction option | No | { enablePartial: true } |
| Throws on failure | InstantiationError | MaterializationError |
Related
instantiatereference — full option reference and examplesmaterializereference — construction helper examples- Picking a method — broader decision guide including
validateandis - Transforms — how
Transform.createregisters a decoder - Runtime decoding across packages — this same ordering, applied to a registry split across two packages