Engine Notes
TypeSea is written for predictable machine behavior after TypeScript emits JavaScript. The goal is not obscurity; the goal is to make object shapes, allocation sites, branch behavior, and validation contracts visible in code.
Hot Path Rules
- Use prototype methods instead of per-instance method closures.
- Use numeric tags for schema, check, issue, and IR node variants.
- Initialize class fields in one constructor order.
- Keep successful
is()validation free of diagnostic allocation. - Allocate
Issueobjects and path arrays only when diagnostics are requested. - Prefer indexed loops on recursive validation paths.
- Precompute object-entry arrays during schema construction.
- After required object fields have proved their data-property descriptors, load descriptor values directly instead of rechecking missing-property fallbacks.
- For all-required strict objects, reject extras by counting own string names and own symbols after field validation. Optional strict objects keep the full key membership scan.
- Keep
compile()andemitAotModule()safe by default. Unsafe mode is an explicit opt-in that may use direct property/index loads and own-enumerable strict-key loops after the caller accepts getter/prototype/symbol-extra risk. - Mark constructed guards out-of-band so normal receivers avoid repeated schema validation while forged receivers still fall back to structural checks.
- Use
Readonly<Record<string, unknown>>after object guards. - Store generated-validator literals, regexps, keysets, and dynamic fallbacks in side tables instead of interpolating user-controlled values into source text.
Type-System Rules
optional(inner)means an object key may be absent.undefinedable(inner)means the key must exist when used in an object shape, but its value may beundefined.nullable(inner)means the value may benull.- Presence-preserving wrappers do not erase optional-key semantics.
numbermeans finite JavaScript number.unknownis the only accepted boundary type for untrusted input.- Builder validation is the hard barrier before a schema reaches the engine.
IR Rules
The public schema tree is the semantic source used by builders and diagnostic
collectors. Boolean validation executes a cached ValidationPlan: schema
identity is lowered into Sea-of-Nodes IR, the optimizer runs, and the plan keeps
both the frozen graph and a schema-specialized predicate kernel.
The graph is not decorative. compile(), AOT emission, and Guard.graph() all
consume the optimized graph held by the plan. Ordinary Guard.is() deliberately
uses the sibling schema-specialized kernel instead of a generic node interpreter,
because per-node dispatch and scratch-slot bookkeeping cost more than they buy
on the most common hot path.
Current lowering hash-conses pure value and predicate nodes. Strict object schemas lower an explicit keyset check into the IR, so extra-key rejection does not depend on out-of-band schema knowledge. Required and optional object fields separate key presence from data-property presence, which keeps accessor-backed properties from executing getters or being misclassified as valid values.
Guard.graph() returns the same optimized graph held by the validation plan.
Public graph values are validated and frozen before leaving the API. The first
optimizer pass performs reachable node elimination and compacts node ids so every
dependency points at an existing dense node index. compile() and AOT emission
use this graph as their predicate source.
Array, tuple, and record schemas lower to native composite IR nodes whose child
schemas are executed through child validation plans. SchemaCheck is reserved
for dynamic schemas such as lazy, refine, and superRefine; the graph
records that callback or resolver-backed semantics are required instead of
pretending they are static predicates.
Runtime Compiler
Compiled guards emit boolean predicates from optimized Sea-of-Nodes graphs and
schema-aware diagnostics collectors for failed values. Runtime is() uses the
plan-owned schema-specialized kernel to avoid recursive node dispatch and scratch
buffer churn. check() first asks the plan predicate for the pass/fail verdict;
successful values skip diagnostic collection, while failed values replay the
diagnostic collector to build paths and issue codes.
User-controlled literals, regexps, object keys, keysets, dynamic schemas, and diagnostic names live in side tables captured by the generated factory. The generated source contains numeric side-table indexes, fixed helper strings, and sanitized function names.
compileBoolean() uses the same predicate emitter but stops before diagnostic
collector emission. That keeps the fail-fast artifact smaller than a full
compiled guard and makes the “boolean only” contract explicit. compileAsync() does not make generated predicates async; it wraps a compiled diagnostic guard
with a separate cooperative evaluator so ordinary compiled hot paths never pay
for await state machines.
The AOT bundler plugins are thin build-time adapters around emitAotModule().
Virtual modules are resolved by id, and the Rollup, Vite, and esbuild macro
paths rewrite only static compileCached("id", ...) calls whose id was declared
in plugin config. No schema expression is evaluated from source text.
Scalar nodes emit direct JavaScript tests where the semantics are local: finite-number checks, integer checks, BigInt bounds, string length bounds, literal equality, and regexp tests all lower without helper calls on the generated hot path.
Array and record IR nodes emit indexed loops. Static child schemas are inlined
into those loops from their optimized graphs, which avoids function-call
boundaries for small scalar or union element contracts. Tuple nodes preserve
descriptor-based element access, and dynamic edges use the same IR-backed
runtime fallback as ordinary guard execution, preserving behavior for lazy, refine, and superRefine.
Union IR now has four increasingly general fast paths. Literal-tag object unions use discriminant dispatch, primitive unions use a compact root-kind dispatch, object unions with required branch keys use presence dispatch, and the remaining mixed unions use ordered root-kind branch probing. Presence dispatch keeps declaration order but skips object branches whose required own data key is absent, which avoids recursive union explosions in AST-like inputs.
Strict object IR emits two shapes. When every declared key is required,
generated validators run the strict-key count before field descriptor reads:
they compare Object.getOwnPropertyNames(value).length with the declared key
count and require Object.getOwnPropertySymbols(value).length === 0. V8
optimizes this count-only path better than a generic Reflect.ownKeys count,
and it rejects obvious extra-key objects before touching field descriptors.
Optional strict objects still emit the full own-key membership scan because a
missing optional key cannot be distinguished by the final key count alone.
compile(..., { mode: "unsafe" }) and emitAotModule(..., { mode: "unsafe" }) switch generated predicates to a
trusted-data code shape. Required object fields whose schemas reject undefined use direct value[key] loads without descriptor or own-key checks.
Required fields that can accept undefined retain an own-key presence guard so
missing required keys do not collapse into valid undefined values. Optional
fields take the direct-load fast path for present non-undefined values and
fall back to an own-key check only for the ambiguous undefined case.
Unsafe array, tuple, record, and discriminant paths also prefer direct loads.
Strict objects use a for...in own-enumerable key loop instead of allocating
own-key arrays.
Object keys that are ASCII identifier names emit as dot-property loads such as value.id; other keys emit as escaped string-literal bracket loads. That is
intentionally not hostile-input equivalent: getters can execute,
prototype-backed values can be accepted, symbol or non-enumerable strict extras
are not rejected, and static property names may appear in unsafe generated
predicate source.
mode: "unchecked" keeps the unsafe direct-read shape and removes strict
extra-key loops. It is a trusted-shape path for objects already normalized by
the caller; strict objects no longer reject any extra keys there.
Fast modes also remove Object.freeze() from successful compiled check() results. The returned object keeps the same { ok: true, value } shape, but it
is intentionally not frozen. Failed diagnostics stay frozen because those
objects are off the success hot path and are often retained for reporting.
Object diagnostics in fast modes are generated from the same direct-read
contract as predicates. Required fields load through value.key, optional
fields use direct load plus an own-key fallback for undefined, unsafe strict
objects scan own enumerable string keys, and unchecked strict objects skip the
strict-key diagnostic scan. Array and tuple diagnostics in fast modes read
items through direct indexes instead of descriptor probes. Record diagnostics
read through record[key]; unchecked mode intentionally keeps inherited
enumerable keys visible. Discriminant diagnostics read the tag directly and
compare literal string cases with strict equality.
FastMode is therefore an engine contract, not a security default:
| Contract | safe | unsafe | unchecked |
|---|---|---|---|
| Getter execution avoided | yes | no | no |
| Prototype-backed fields rejected | yes | no | no |
| Enumerable strict extras rejected | yes | yes | no |
| Symbol and non-enumerable strict extras rejected | yes | no | no |
Successful compiled check() result frozen | yes | no | no |
The intended rule is simple: boundary data uses safe; trusted normalized
records may use unsafe; fixed-shape DTOs owned by the caller may use unchecked.
Recursion
Lazy schemas resolve their getter once per guard instance. Recursive validation therefore sees stable schema identity, and repeated validations do not rebuild the recursive schema graph.
Recursive validation uses a root-local active pair table keyed by runtime object identity and schema identity. Re-entering the same schema/value pair short-circuits that edge, which lets cyclic object graphs validate finitely while still checking the original object fields on the outer frame.
Compiled lazy, refine, and superRefine fallbacks use the same IR-backed
runtime path, so recursive behavior stays consistent across execution engines.
When superRefine emits custom issue paths or messages, the generated
diagnostic fallback copies those nested issues under the current compiled path
prefix.
checkFirst() has a separate generated collector. It returns one frozen issue
as soon as the first diagnostic is known, instead of running the full check() collector and truncating its issue array.
JSON Schema Export
JSON Schema export succeeds only when the TypeSea schema can be represented over
JSON-compatible input values without semantic loss. Runtime-only concepts return
typed Result errors.
Export diagnostics keep paths at the failed child slot instead of collapsing everything to the parent container. Nested unsupported schemas therefore remain actionable without reconstructing the schema tree manually.
Literal checks use Object.is in runtime-plan and compiled paths. Diagnostics
use the same literal formatting, including -0, so compiled and runtime-plan check() results stay byte-for-byte comparable in tests.
Benchmark Scope
The benchmark suite keeps two questions separate:
compile.bench.tscompares TypeSea runtime-plan and compiled validators over the same TypeSea schema.ecosystem.bench.tscompares TypeSea runtime-plan, TypeSea compiled, Zod, Valibot, and Ajv over one JSON-compatible strict-object contract.
Zod, Valibot, and Ajv are dev dependencies for measurement only. They are not
imported by src, and package policy rejects runtime, peer, optional, or
bundled dependency fields before release.
Last clean local benchmark on 2026-07-09 KST reported these ecosystem paths over the
JSON-compatible strict-object benchmark. The committed source of truth is bench/results/latest.json; npm run bench:record refreshes that summary and
the README SVG from the median of 3 full Vitest runs. npm run bench:compare checks the committed summary against release regression
floors for unchecked valid hot path, safe invalid fast-fail, safe valid
throughput, and presence-dispatch object union paths. check:benchmarks runs
the same floor check after verifying the SVG is fresh and the committed
benchmark summary matches the current package version.
| Case | TypeSea runtime plan | TypeSea compiled safe | TypeSea compiled unsafe | TypeSea compiled unchecked | Ajv compiled |
|---|---|---|---|---|---|
Valid is() | 428,637 hz | 4,952,729 hz | 31,992,573 hz | 39,651,592 hz | 4,047,925 hz |
Valid check() | 414,697 hz | 4,422,123 hz | 25,915,737 hz | 32,844,863 hz | 4,238,051 hz |
Invalid is() | 2,891,226 hz | 40,230,201 hz | 49,473,616 hz | 48,593,951 hz | 27,612,151 hz |
Invalid check() | 345,894 hz | 1,714,191 hz | 2,689,279 hz | 3,207,055 hz | 29,466,173 hz |
Benchmark numbers are machine-local telemetry. They are useful for catching regressions, not for promising a fixed throughput floor. Unsafe and unchecked numbers are not hostile-input equivalent to safe mode.