TypeSea is a zero-runtime-dependency TypeScript runtime narrowing library built around immutable guards, optimized Sea-of-Nodes validation plans, runtime compilation, and AOT source generation.
Latest committed local benchmark on 2026-07-06 KST:
npm run bench:record, median of 3 full runs, strict-object contract,
operations per second on one machine. The chart is generated from
bench/results/latest.json.
TypeSea safe compiled validators are already in Ajv's boolean hot-path class while keeping descriptor-based hostile-input semantics. Unsafe and unchecked FastMode are the bragging-rights path for trusted normalized data: direct field loads, allocation-light strict-key loops, and V8-friendly monomorphic codegen.
Goal: not "probably valid", but provably parity-tested validation that never executes user code, never throws on expected failures, and never leaks mutable state across a public boundary.
Important
TypeSea is designed for hostile boundary data: property reads go through
descriptors so user getters never execute, __proto__/constructor keys
are handled with null-prototype lookups, user regexes are cloned and
lastIndex-reset, and cyclic inputs validate finitely. Expected failures
return frozen Result values — any, try, and catch are banned from the
entire codebase and enforced by policy gates.
Warning
unsafe and unchecked are not public-boundary modes. They are for
trusted, already-normalized data where the caller accepts getter execution,
prototype-backed values, and weaker strict-extra-key guarantees. Use the
default safe mode for external input.
Many validation libraries fall short when you care about:
- untrusted input that fights back (getters with side effects, prototype pollution keys, forged schema objects, revoked proxies)
- identical verdicts across execution strategies (runtime plan vs compiled vs AOT-generated validators)
- diagnostics without exceptions (
Resultvalues instead ofthrow) - immutability at every public boundary
TypeSea focuses on:
- no user-code execution during validation
- runtime plan / compiled / AOT parity, enforced by a seeded generative fuzzer
- injection-safe code generation (side tables, never string interpolation)
- explicit presence semantics (
optionalvsundefinedable)
- Zero dependencies: no runtime, peer, optional, or bundled dependencies — mechanically enforced by package policy before every release.
- Three engines, one semantics:
is()/check()execute a cached validation plan,compile()emits runtime predicates from optimized IR, andemitAotModule()emits standalone validator source. The runtime plan owns both the graph and a schema-specialized kernel, so the graph is the source of truth for generated validators without forcing ordinaryis()through a per-node interpreter. Parity is fuzz-tested with sparse arrays, accessor properties, symbol keys, and non-enumerable extras included. - Frozen public surface: guards, schemas, graphs, diagnostics, and JSON Schema payloads are frozen before they cross an API boundary.
- Lossless-only export: JSON Schema and AOT export succeed only when no semantics would be lost; runtime-only contracts return typed issues instead of silently weakening the schema.
Note
TypeSea is ESM-only: the package ships "type": "module" with no
CommonJS build. Node.js >= 20.19 can also load it via require(esm)
through the default export condition.
import { compile, t, toJsonSchema, type Infer } from "typesea";
const User = t.strictObject({
id: t.string.uuid(),
email: t.string.email(),
age: t.number.int().nonnegative(),
role: t.enum(["admin", "user"]),
tags: t.array(t.string.min(1)).max(8)
});
type User = Infer<typeof User>;
// 1) Boolean narrowing — avoids diagnostic allocation on success
if (User.is(input)) {
input.id; // narrowed
}
// 2) Immutable diagnostics — frozen Result, never throws on expected failure
const checked = User.check(input);
if (!checked.ok) {
console.log(checked.error); // frozen issue list with paths
}
// 3) Hot path — generated validator code
const FastUser = compile(User, { name: "isUser" });
// 4) Interop — lossless-only JSON Schema export
const schema = toJsonSchema(User);Use is() for the allocation-light boolean path. Use check() when callers
need the full immutable diagnostic list, or checkFirst() when a hot rejection
path only needs one machine-readable issue. Use compile() or emitAotModule()
when a stable schema is hot enough to deserve generated validator code.
Compiled and AOT checkFirst() use a dedicated first-fault collector instead
of building the full issue list and slicing it afterward.
Caution
compile() builds the validator with new Function, which throws under a
Content-Security-Policy that forbids unsafe-eval. In CSP-restricted
environments, generate validator source ahead of time with
emitAotModule() instead.
import {
compileAsync,
compileBoolean,
compileCached,
createTypeSeaVitePlugin,
warmup
} from "typesea";
const FastUser = compileCached("user:v1", () => User, { name: "isUser" });
const BooleanUser = compileBoolean(User, { name: "isUserBoolean" });
const AsyncUsers = compileAsync(t.array(User), {
name: "isUsersAsync",
yieldEvery: 4096,
yieldTimeout: 5
});
warmup([User, { key: "user:v1", guard: User, options: { name: "isUser" } }]);
export default createTypeSeaVitePlugin({
entries: [{ id: "user:v1", guard: User, options: { name: "isUser" } }],
transformCompileCached: true
});Use compileCached() when schema construction might otherwise happen inside a
request handler. It caches by caller-owned semantic keys, so cold-start work can
be paid once and reused deliberately. compile() also caches repeated calls for
the same guard instance, and development builds warn when repeated codegen comes
from the same callsite.
Use warmup() in Lambda/serverless module scope or service startup to prefill
compiled guards before the first request. Use compileBoolean() when a hot
path only needs true/false; it emits no diagnostic collectors at all. Use
compileAsync() or isAsync() for huge arrays, records, maps, sets, or object
graphs that should yield back to the Node.js event loop between validation
chunks.
The zero-dependency AOT plugin helpers expose Rollup, Vite, and esbuild
compatible plugin objects. All three can rewrite static
compileCached("id", ...) calls into imports from typesea:aot/<id> when the
entry is listed in the plugin config. esbuild reads source through an optional
readFile hook or a dynamic node:fs/promises import inside setup().
const FastButLooseUser = compile(User, {
name: "isUserFast",
mode: "unsafe"
});
const FastTrustedShapeUser = compile(User, {
name: "isUserTrustedShape",
mode: "unchecked"
});compile(..., { mode: "unsafe" }) and
emitAotModule(..., { mode: "unsafe" }) emit the V8-friendliest predicate
TypeSea can generate: required object fields are read with direct bracket
access, arrays and tuples use direct indexed loads, discriminants avoid
descriptor reads, and strict-object extras are checked with an allocation-free
for...in loop. This mode is for trusted, already-normalized data on extremely
hot paths.
The default is still mode: "safe". Unsafe mode may execute getters, may accept
prototype-backed values, and strict objects do not reject symbol or
non-enumerable extras. Use it only when the caller owns the object graph or has
already normalized input into plain data records. Unsafe generated predicates
may also embed escaped static property keys directly in source so V8 can use
ordinary property-load inline caches.
mode: "unchecked" goes one step further: it trusts the object shape and skips
strict extra-key loops entirely. That is the fastest path for already-owned DTOs,
but strict objects no longer reject any extra keys.
In unsafe and unchecked modes, successful compiled check() calls return a raw
{ ok: true, value } object instead of freezing the success result. Failed
diagnostics are still frozen. Safe mode keeps the fully frozen Result contract.
FastMode diagnostic collectors also use the same trusted direct-read object
shape where possible, so their issue codes can be less hostile-input-specific
than safe mode for missing/accessor-backed fields and sparse/accessor-backed
array or record slots. Discriminant diagnostics also read tags directly.
| Contract | safe |
unsafe |
unchecked |
|---|---|---|---|
| Executes user getters | no | possible | possible |
| Accepts prototype-backed fields | no | possible | possible |
| Rejects enumerable extra keys in strict objects | yes | yes | no |
| Rejects symbol or non-enumerable strict extras | yes | no | no |
Freezes successful compiled check() result |
yes | no | no |
| Intended input | hostile boundary data | trusted normalized records | trusted fixed-shape DTOs |
Use safe at every public boundary. Use unsafe only after data has already
been normalized into ordinary records. Use unchecked only when the caller owns
the shape and treats extra-key rejection as unnecessary work.
Object presence is explicit — two different wrappers express two different contracts:
| Wrapper | Key may be absent | Value may be undefined |
Inferred type |
|---|---|---|---|
t.optional(inner) |
yes | no | key?: T |
t.undefinedable(inner) |
no | yes | key: T | undefined |
t.nullable(inner) |
— | value may be null |
key: T | null |
Note
Presence survives wrapper composition: t.nullable(t.optional(x)) still
means "the key may be absent" — inference and runtime agree on this under
exactOptionalPropertyTypes.
TypeSea keeps the public schema tree for builder validation and diagnostics,
then lowers each schema identity into a cached validation plan. The plan owns an
optimized Sea-of-Nodes graph and a schema-specialized predicate kernel.
Guard.is() uses the kernel to avoid per-node interpreter dispatch, while
compile() and emitAotModule() emit predicates from the optimized graph.
check() first asks the same plan for the verdict; failed values then replay
the schema-aware diagnostic collector to produce issue paths and codes.
builder -> frozen schema -> lower -> Sea-of-Nodes IR -> optimize
optimize -> ValidationPlan { graph, schema kernel }
schema kernel -> Guard.is() / check() preflight
graph -> compile() predicate / emitAotModule() predicate / Guard.graph()
failed check() -> schema-aware diagnostic collector
Important
Generated validators keep user-controlled values out of source text: literals, regexps, object keys, keysets, and dynamic schema fallbacks live in side tables referenced by numeric index. Hostile property names cannot escape into generated code — this is pinned by dedicated injection-audit tests.
Last local benchmark on 2026-07-06 KST, using npm run bench:record with the
median of 3 full Vitest runs over the benchmark strict-object contract. The raw
Vitest JSON is stored in bench/results/raw.json,
and the stable summary used by the README graph is stored in
bench/results/latest.json. These are operations
per second on one machine, not release guarantees.
| Valid object path | hz |
|---|---|
TypeSea interpreted is() |
341,332 |
TypeSea compiled safe is() |
3,840,854 |
TypeSea compiled unsafe is() |
27,464,645 |
TypeSea compiled unchecked is() |
29,647,233 |
Zod safeParse |
911,576 |
Valibot safeParse |
946,246 |
| Ajv compiled | 2,682,380 |
| Valid diagnostic path | hz |
|---|---|
TypeSea interpreted check() |
294,582 |
TypeSea compiled safe check() |
2,914,942 |
TypeSea compiled unsafe check() |
21,517,947 |
TypeSea compiled unchecked check() |
31,707,555 |
Zod safeParse |
883,138 |
Valibot safeParse |
893,898 |
| Ajv compiled | 2,876,907 |
| Invalid object path | hz |
|---|---|
TypeSea interpreted is() |
2,223,276 |
TypeSea compiled safe is() |
30,513,434 |
TypeSea compiled unsafe is() |
28,172,129 |
TypeSea compiled unchecked is() |
36,659,550 |
Zod safeParse |
60,043 |
Valibot safeParse |
533,818 |
| Ajv compiled | 15,870,460 |
| Invalid diagnostic path | hz |
|---|---|
TypeSea interpreted check() |
280,569 |
TypeSea compiled safe check() |
1,460,301 |
TypeSea compiled unsafe check() |
2,144,535 |
TypeSea compiled unchecked check() |
2,658,950 |
Zod safeParse |
59,685 |
Valibot safeParse |
592,515 |
| Ajv compiled | 19,847,089 |
| Presence-dispatched object union | hz |
|---|---|
| TypeSea interpreted logical branch | 893,483 |
| TypeSea compiled safe logical branch | 3,671,517 |
| TypeSea compiled unsafe logical branch | 31,475,593 |
| TypeSea interpreted fallback record branch | 355,598 |
| TypeSea compiled safe fallback record branch | 4,724,044 |
| TypeSea compiled unsafe fallback record branch | 9,841,223 |
| TypeSea interpreted invalid branch | 520,812 |
| TypeSea compiled safe invalid branch | 11,309,279 |
| TypeSea compiled unsafe invalid branch | 14,484,249 |
The safe compiled path stays close to Ajv while retaining TypeSea hostile-input semantics: descriptor-based property reads, symbol/non-enumerable strict-key rejection, presence semantics, immutable diagnostics, and TypeScript guard inference. Unsafe and unchecked compiled modes are faster because they deliberately give up parts of that hostile-input contract.
All public entry points are exported from the package root; builders are also
grouped under the t table.
| Area | Entry points |
|---|---|
| Scalar guards | t.unknown, t.never, t.string, t.number, t.date, t.bigint, t.symbol, t.boolean, t.null, t.undefined, t.void |
| String checks | .min, .max, .length, .nonempty, .regex, .startsWith, .endsWith, .includes, .uuid, .email, .url, .isoDate, .isoDateTime, .ulid, .ipv4, .ipv6 |
| Number checks | .int, .finite, .safe, .gte, .lte, .min, .max, .gt, .lt, .multipleOf, .positive, .nonnegative, .negative, .nonpositive |
| Date checks | .min, .max |
| Literal and containers | t.literal, t.enum, t.array, t.tuple, tuple rest, t.record, t.map, t.set, t.json |
| Array checks | .min, .max, .length, .nonempty |
| Objects | t.object, t.strictObject, extend, safeExtend, merge, pick, omit, partial, deepPartial, required, strict, passthrough, strip, catchall |
| Runtime object contracts | t.instanceOf, t.property, guard.property |
| Composition | t.union, t.discriminatedUnion, t.intersect, guard.intersect |
| Presence wrappers | t.optional, t.undefinedable, t.nullable, t.nullish |
| Dynamic contracts | t.lazy, t.refine, t.superRefine, guard.superRefine |
| Area | Entry points |
|---|---|
| Sync decoders | guard.transform, guard.pipe, guard.default, guard.prefault, guard.catch, t.decoder, t.transform, t.pipe, t.default, t.defaultValue, t.prefault, t.catch, t.codec, t.coerce, t.string.trim(), t.string.toLowerCase(), t.string.toUpperCase() |
| Async decoders | t.asyncDecoder, t.asyncRefine, t.asyncTransform, t.asyncPipe |
| Area | Entry points |
|---|---|
| Guard methods | guard.is(), guard.check(), guard.checkFirst(), guard.graph() |
| Generated validators | compile, emitAotModule |
| JSON Schema | toJsonSchema |
| Messages | formatIssue, formatIssues, flattenIssues, withMessages |
| Area | Entry points |
|---|---|
| Messages / i18n | formatIssue, formatIssues, flattenIssues, withMessages, defineMessages |
| tRPC | toTrpcParser, toAsyncTrpcParser |
| Fastify | toFastifyRouteSchema, toFastifyValidatorCompiler |
| React Hook Form | toReactHookFormResolver |
Adapters accept compiled guards too. Compile once at startup, then pass the compiled guard into parser or validator-compiler adapters so framework hot paths reuse the generated predicate.
const FastUser = compile(User);
const trpcParser = toTrpcParser(FastUser);
const fastifyCompiler = toFastifyValidatorCompiler(FastUser);
// Trusted normalized data only: trades hostile-input hardening for direct reads.
const UnsafeUser = compile(User, { mode: "unsafe" });
const internalParser = toTrpcParser(UnsafeUser);Tip
Match the inference alias to the source kind: Infer<> for guards,
InferDecoder<> for decoders, InferAsyncDecoder<> for async decoders.
Applying Infer<> to a decoder resolves to never — if a downstream type
suddenly collapses, this is the first thing to check.
Deliberate, documented, and pinned by tests:
| Input | Behavior |
|---|---|
NaN, Infinity |
rejected by t.number (finite numbers only); t.literal(NaN) matches NaN |
-0 vs 0 |
literals match via Object.is; diagnostics format -0 distinctly |
| Getter-backed properties | never executed; treated as missing/invalid data |
__proto__, constructor keys |
validated as plain own keys, no pollution |
| Sparse array holes | read as undefined without executing accessors |
| Strict object extras | rejected via Reflect.ownKeys — including symbol keys and non-enumerable properties |
catchall extras |
unknown own keys are descriptor-read and validated by the catchall schema |
strip() |
validation-only alias for accepting extras; TypeSea does not clone stripped output |
t.date |
accepts valid JavaScript Date objects; .min and .max compare epoch milliseconds without reading user-overridable Date methods |
t.map, t.set, t.instanceOf |
runtime-only contracts; JSON Schema and AOT export reject them instead of weakening semantics |
property |
validates own data properties only; getter-backed properties are rejected |
| Global-flag regexes | cloned at construction; lastIndex reset before every test |
| UUID | accepts RFC 9562 versions 1–8 plus the nil UUID |
| Cyclic input values | validate finitely via (value × schema) active-pair tracking |
| Nesting depth | capped at 256 recursive frames; deeper input fails instead of overflowing the stack |
Warning
Recursive guards need an explicit type annotation. TypeScript cannot infer a self-referential initializer (TS7022):
interface ListNode {
readonly value: string;
readonly next?: ListNode;
}
const Node: Guard<ListNode> = t.lazy((): Guard<ListNode> =>
t.object({ value: t.string, next: t.optional(Node) })
);- Boundary data enters as
unknown. Do not pre-narrow withas— the builder API is typed so that narrowing happens through validation. - Recursive contracts go through
t.lazy. Direct schema object cycles are rejected at construction. - Choose the engine by schema lifetime. One-off schemas: runtime plan.
Stable hot schemas:
compile(). CSP environments or build-time generation:emitAotModule(). - Shape object unions by required keys.
t.union(t.object({ and: ... }), t.object({ or: ... }), t.object({ path: ... }))lowers to presence dispatch and skips impossible branches. Do not model an optional operator bag as many near-identical union branches; use one object andsuperRefinefor "at least one operator exists". - Decoders do not embed in object shapes. Compose transformations with
t.pipearound a validated shape instead of mixing decoders intot.objectentries.
Every gate that CI runs is a local npm script:
npm run check # policy, docs, typecheck, lint, tests, build, dist, API snapshot, pack
npm run check:consumer # tarball install + runtime/type smoke in a temp project
npm run bench:compare # compare committed benchmark JSON against release floors
npm run bench:record # full benchmark run + committed JSON/SVG refresh
npm run bench:render # regenerate SVG from committed benchmark JSON
npm run bench -- --run # benchmark smoke
npm run pack:dry # package contents dry run
npm run release:check # the full pre-publish gate (everything above)
npm run release:publish # npm publish with provenance and ignored lifecycle scriptsnpm run release:check runs the same gate expected before publishing:
typecheck, lint, tests, build, docs smoke, dist policy, public API snapshot,
package contents, consumer install, benchmark smoke, and pack dry run.
CI executes it on Node 20.19, 22, and 24; releases publish with npm provenance.
Release path:
- Push a
vX.Y.Ztag or run the GitHubReleaseworkflow with that tag. - The release workflow verifies that the tag matches
package.json. - The same release workflow runs
npm run release:check, thennpm run release:publish, which expands tonpm publish --provenance --access public --ignore-scripts. - The workflow verifies npm registry visibility and then creates the GitHub Release.
Local publishing with NPM_TOKEN is reserved for manual recovery releases. It
must still run npm run release:check first, and it cannot attach GitHub OIDC
provenance.
Note
Benchmark comparison packages (Zod, Valibot, Ajv) are dev dependencies only —
package policy rejects them from every runtime dependency field. The
benchmark suite reports both boolean-path and diagnostic-path
(check() vs safeParse) comparisons, so numbers stay apples-to-apples.
check:benchmarks also verifies the committed summary against release floors
for unchecked valid, safe invalid, safe valid, and presence-dispatch union
paths.
Existing schemas keep working. 0.4.0 is a minor release because it adds new
public APIs: superRefine, compileCached, createCompileCache, warmup,
compileBoolean, cooperative async validation, and zero-dependency Vite,
Rollup, and esbuild AOT plugin helpers. Compiled object unions are also faster
when branches have required keys, such as AST or query objects shaped by and,
or, not, or path fields.
No application code changes are required. 0.3.2 is a performance-regression
hardening patch: it adds benchmark floors, pins representative generated source
fingerprints, strengthens FastMode fuzz parity, and normalizes unions by
flattening nested unions, removing never, and absorbing unknown.
No application code changes are required. 0.3.1 is a release-hardening patch:
it tightens manual release tag handling, documents npm provenance expectations,
adds a security policy, and verifies that npm exposes the published version after
the GitHub publish workflow completes.
MIT License. See LICENSE.