Under the hood — the engine

Two things happen: a schema is derived from your type at build, and a validator runs against the data at runtime. Both are designed to disappear — nothing to write, nothing you can feel.

Schemas come from resolved types

The plugin never parses type syntax. It asks the TypeScript checker (through ts-morph) for the resolved type at each site. So anything the compiler can reduce to a concrete shape is fair game — generics, indexed access, conditional types, mapped types, utility types (Partial, Pick, Omit, Required). By the time t12n sees the type, it’s already a plain structure.

type EventMap = {
  user:    { id: string; name: string; role: 'admin' | 'user' }
  payment: { amount: number; currency: string; timestamp: Date }
}
type EventData<T extends keyof EventMap> = EventMap[T]

// a conditional + indexed type at a boundary:
const p: EventData<'payment'> = JSON.parse(raw)

t12n resolves EventData<'payment'> first, then derives the schema:

{ amount: number, currency: string, timestamp: Date (instanceof) }

No special case for the conditional/indexed type — the checker reduced it before t12n looked. Mapped types, nested index access (System[K]['config']), T | null, arrays and enums all come out the same way. Built-in classes (Date, Map, Set, RegExp, typed arrays) are checked with instanceof; an index signature ({ [k: string]: T }) becomes a record; a template-literal type validates as a string.

Two-tier runtime

For production builds the plugin compiles each type into a dedicated validator function and bakes it into the bundle — at runtime no schema is interpreted:

// generated once per type, hoisted into the module
const __v0 = (v) => {
  if (v === null || typeof v !== 'object') return fail('', 'object', v)
  if (typeof v.amount   !== 'number') return fail('amount', 'number', v.amount)
  if (typeof v.currency !== 'string') return fail('currency', 'string', v.currency)
  if (!(v.timestamp instanceof Date)) return fail('timestamp', 'Date', v.timestamp)
  return { amount: v.amount, currency: v.currency, timestamp: v.timestamp }
}

Straight-line code with static property access — exactly what you’d write by hand. Recursive types and the live Proxy guard fall back to a compact tree-walking interpreter; everything else takes the compiled path. There’s no eval / new Function at runtime, so it works under a strict Content-Security-Policy.

It’s effectively free

The generated validator never coerces, and it allocates nothing for data that already matches: unknown keys are stripped copy-on-write, so a clean object is returned as-is. In the repo’s benchmark (an array of 100 nested objects):

ops/srelative
by hand~775,000~1.2× faster
t12n (compiled)~640,000baseline
Zod v4~75,000~8.5× slower

About 8× faster than Zod, and within ~20% of a validator written by hand for this exact type — because the generated code is essentially that. Fast enough that the check is invisible next to the network request or JSON.parse that produced the data. Run it yourself with node bench/runtime.bench.mjs.