Roadmap

August 17, 2026 · View on GitHub

Living document. Captures what's implemented, what's deliberately out of scope (and why), and open questions parked for later.


Status snapshot

ComponentStatusNotes
Go resolver + checker integrationscanFiles / dump / setSources / reset / tsCompile / transform / generate / enrich ops over stdio (ts-runtypes serve)
Factory referenceFull factory set — validate / serialization / formats; the factory-by-factory capabilities and the intentional divergences (strictTypes, paramsSlice, reflectFunction, toJSCode) are recorded in ARCHITECTURE.md → "Factory reference"
Reflection-shape projection*checker.Typeprotocol.Type discriminated union, dedup by structural id
Wire formatsJSON dump + per-entry virtual modules (--out-json / --out-modules)
Vite pluginbyte-offset rewriter + import injection, per-entry virtual:rt/* modules
Go fixture testsF1–F40 + atomic / object / circular kinds
Vite plugin testsrewrite, atomic, wrapping + projection suites — all green
validate RT emitevery node category covered; see test/suites/validation/validate.test.ts
templateLiteral projection+emitregex-compile at RT-build time; also wired into index-signature key patterns
Native containers (Map/Set/Promise)instanceof + iteration over .entries() / .values(); thenable check
DocsARCHITECTURE.md "Reflection shape" section
String type-formats@ts-runtypes/core/formats — StringFormat/UUID/Date/Time/DateTime/IP/Domain/Email/URL/DefaultStringFormats; brand scanner + idempotent hashing
Date/time min/max boundsFormatStringDate/Time/DateTime accept optional min/max: an absolute literal in the field's own layout OR a relative now±P… ISO-8601 duration. Per-kind component restriction (date→Y/M/W/D, time→T-section H/M/S, dateTime→both) validated Go-side (FMT002). Go: ts-go-runtypes/internal/cachegen/typefunctions/formats/datetime/{bounds,literals,boundcodegen}.go; JS comparison fns: formats/datetime/dateTime-pure-fns.ts
Native Date type-formatFormatDate<P> brands the JS Date object with the same min/max params (both component kinds). The brand lifts off the Date & {brand} intersection onto a KindClass/SubKindDate node (splitBuiltinClassBrand in intersection_collapse.go); emitter formats/datetime/nativeDate.go compares getTime(). No new serialisation — Date round-trips via the default serialisers.
Temporal types (all 8)Temporal.Instant/ZonedDateTime/PlainDate/PlainTime/PlainDateTime/PlainYearMonth/PlainMonthDay/Duration validate + serialize + mock through every RT-fn. Treated as builtin classes like Date: KindClass + SubKind 2101–2108 + ClassRef.Builtin="Temporal.X". Namespace-qualified detection (symbol.Parent=="Temporal"; registry in protocol/temporal.go). validate=instanceof; JSON=toJSON()/from(); binary=string-encoded (lossless for nanosecond + plain types). Requires ESNext.Temporal in the consumer's tsconfig lib — a Temporal.* type that resolved to any (lib missing) raises a build error (TMP001) instead of silently emitting an accept-anything validator. The min/max FORMAT family (FormatTemporalX, orderable kinds) ships in packages/ts-runtypes/src/formats/datetime/temporalFormats.ts.
Number/bigint type-formatsGo: ts-go-runtypes/internal/cachegen/typefunctions/formats/numeric/{numberformat.go,bigintformat.go}; JS: packages/ts-runtypes/src/formats/{numberFormats.ts,bigintFormats.ts}
Binary serializationGo: ts-go-runtypes/internal/cachegen/typefunctions/{binary_to.go,binary_from.go}; JS: packages/ts-runtypes/src/{createRTFBinary.ts,runtypes/dataView.ts}; allOptional/paramsSlice router conveniences intentionally not ported (see "Binary serialization — function-params router conveniences")
Generic type-metadata (typeMeta)any atomic & { obj } intersection surfaces its object members as opaque typeMeta (renamed from decorators; subsumes the old number brand). TS @decorator-syntax capture + validating constraint decorators (MinLength<5>) remain out of scope
infer kind❌ pendingreserved in the enum, only meaningful inside unresolved conditional types
Pre-process build mode❌ pendingbundler-agnostic CLI that writes the cache without Vite
Format conversion (convert CLI)✅ shippedts-runtypes convert --to type|builders [--check] [--out-dir] rewrites declarations between the two authoring forms over the reflection graph (internal/convert printers), id-preserving by construction — pinned by chain tests, the seeded sweep (pnpm rtx core fuzz convert, C1/C2/C4/C5) and the canonical-graph no-info-loss oracle (C6). Covers atoms, literals, the full format-family roster, arrays/tuples/objects/records/unions (tuples print the group form (RT.tuple({required: […]})) on the builders target, labeled ones wrapping each element in RT.slot), enums, classes, natives (Date/Map/Set/Promise/RegExp), Temporal (all 8 types + the 6 orderable branded families; a Temporal type resolving to any because the lib is missing refuses with CNV007 instead of cementing any), functions (all-required named params print RT.func({params: [RT.slot(…)…], ret}); optional/rest params keep the escape), template literals, brands/TypeMeta, circulars (RT.circular/self() ⇄ own-name) and multi-file sets with import management (outside-set references error, CNV004). Shapes whose identity is a NAME (enums, user classes, cross-declaration references, method members) plus functions with an optional or rest parameter, template literals and bigint literals keep the getRunType escape. The refusal list lives in packages/ts-runtypes/test/features/unsupported-conversion.test.ts — one runnable case per refusal against the real binary, mirrored reader-facing in the website's source-conversion guide. Do not restate it here: a row that starts converting fails that test, which is what keeps the list from drifting. (Enum-member references are NOT refused — they convert by normalizing to their literal value, so the id is unchanged.) Container-level payloads inside a cycle all convert: format params, contains / patternProperties / propertyNames slots, labeled tuples (optional slots included) and branded Temporal values (docs/done/circular-brand-substitution.md + docs/done/circular-temporal-brand-divergence.md). Record: docs/done/format-conversion-layer.md + docs/done/format-conversion-completion.md
Serializer circular-detectionruntype projection sets IsCircular (serialize.go circularIDs); the typefunctions walker's DefaultIsRTInlined forces a dependency call for circular nodes, so each cycle breaks into one factory per node. Covered by F29–F34

Compile-time only — what we will never capture

RunTypes is a compile-time, structural reflection system. The cache is a JSON-shaped graph of Type nodes; the only legitimate runtime-valued payload it carries is literal data (numbers, strings, booleans, null, undefined, bigints, regexps, symbols-by-description). Every other field that exists in the runtypes runtime model but only has meaning as a live JS value is deliberately not captured, and there is no plan to add it.

This is a design choice, not a missing feature: structural type checking only needs the shape, and binding the cache to live JS values would re-introduce the bundler/tooling coupling we left tsc to escape.

Runtime-only fieldWhy we won't emit it
TypeFunction.function?: FunctionThe closure is a JS value. Structural validation needs the signature (parameters + return), which we already emit. If a consumer needs to call the function, they import it.
TypeClass.classType: ClassTypeThe constructor reference is a JS value. We emit the structural shape (types, extendsArguments, implements) and classRef provenance for builtins (Date/Map/Set/RegExp resolved to globalThis.<Name> in the .ts footer). User-class constructor wiring is not planned.
TypeEnum.enum: objectEnum object identity is a JS value. We emit values (and would emit a synthetic {[name]: value} for const enums if needed) — sufficient for structural checks.
default?: () => any (param/property)Default expressions are arbitrary JS. Literal defaults (5, "foo", true, null) are inlined; non-literal defaults are dropped with flags: ["nonLiteralDefault"].
RTContainerConsumer-side RT cache. Populated lazily by the runtypes runtime on first use.
TypeInfer.set(type)Runtime mutation hook for unresolved conditional types. The checker has already resolved them by the time we project, so consumers never see an unresolved infer T.

Literal regexps — the one transform

JSON cannot carry a RegExp instance, but the literal RegExp is compile-time-known data (source + flags). The serializer encodes it as {regexp: {source, flags}} in JSON; the generated .ts artifact's footer rehydrates it via t.literal = new RegExp(source, flags). Same pattern as bigint (string + BigInt(...)) and symbol (description + Symbol(...)). Consumers reading the JSON directly get the structured form.


Known gaps with planned workarounds

These are real reflection features we intend to ship; each has a concrete approach.

Bun loader (deferred)

A Bun.plugin transpile-on-load loader that calls the resolver per file would be a dev-friendly path for bun runtimes (@mionkit/bun and similar). Deferred to a follow-up package; --compile (the batch build mode) is the documented bun path in the meantime. This is a RunTypes-owned future package, not something a consuming framework should hand-roll.

Reflection features that need AST-level scanning beyond tsgo's checker

FeatureWhere it livesApproach
String type-formats (FormatEmail, FormatUUIDv4, …)@ts-runtypes/core/formats (JS) + ts-go-runtypes/internal/cachegen/typefunctions/formats/ (Go)Done. The TypeFormat<Base, Name, Params, Brand> brand lowers to a Base & {__rtFormatName; __rtFormatParams} intersection; the scanner in ts-go-runtypes/internal/cachegen/runtype/typeid/formats.go lifts it into RunType.FormatAnnotation and folds the canonicalised params into the structural id (idempotent cache key). Per-format Go emitters splice the validator into the validate / validationErrors body.
Number/bigint formats (FormatInteger, FormatFloat, …)@ts-runtypes/core/formats (JS) + ts-go-runtypes/internal/cachegen/typefunctions/formats/numeric/ (Go)Done. Reuses the same format pipeline as string formats. Go: numberformat.go / bigintformat.go; JS: numberFormats.ts / bigintFormats.ts.
Decorators (MinLength<5>, Email, etc.)Comment-pragma or branded type aliases parsed by a TS transformer.The format brand scanner is the first instance of this pattern. General-purpose decorators (arbitrary brand objects beyond the format name+params shape) still need their own recognition pass.
inlined: true flagSet when a type is inlined rather than referenced by name.Derive from "did we have an alias symbol?" — emit inlined: true for anonymous types. Field is already in the protocol, just not populated.
originTypes: { typeName, typeArguments }[]Tracks each layer of type-alias unwrapping.Walk the alias chain in tsgo (each alias has a target). Add when needed — not blocking for the runtypes RT.
indexAccessOriginProvenance for T["key"] resolved types.tsgo's IndexedAccessType has the container + index types. Emit when we hit TypeFlagsIndexedAccess.

validate emit — complete

Every validate node category is covered with end-to-end tests: all active validation cases passing, 0 deferred in packages/ts-runtypes/test/suites/validation/validate.test.ts (one describe(...) block per category, each with its own drift-guard counter).

Categorydescribe blockHighlights
Atomic (any/unknown/never/void/null/undefined/string/number/boolean/bigint/symbol/object/regexp/literal/enum/Date)validate / ATOMICIncludes noLiterals option variants.
Arrayvalidate / ARRAYCircular self-reference, 2D / 3D, noIsArrayCheck, array-of-objects, array-of-unions, array-of-tuples, symbol[] non-serializable.
Object (interface / class / property / method / index signature / call signature / function)validate / OBJECTPlain user class with prototype-filter, RpcError-shape, all-optional w/ allOptionalCode guard, callable interface (isCallable() branch), Parameters<F> for CallSignature param validation, Record<UnionKey, V>.
Tuplevalidate / TUPLEOptional members, rest ([A, ...B[]]), circular self-reference, non-serializable function slot, trailing-optionals chain, named tuple labels.
Unionvalidate / UNIONUnion-of-objects, discriminated unions, union with methods, circular unions, intersection (resolved to ObjectLiteral by tsgo).
TemplateLiteralvalidate / TEMPLATE_LITERALRegex-escape edge cases, multi-segment URLs, nested-in-object, index-signature key pattern, union-placeholder.
Nativevalidate / NATIVEMap<K, V> (instanceof + .entries()), Set<T> (.values()), Promise<T> (thenable), Awaited<P>.
Utilityvalidate / UTILITYPartial / Required / Pick / Omit / Exclude (atomic + object-union) / Extract / NonNullable / ReturnType / Readonly + intersection-with-required-override + Omit-keeping-optional. tsgo resolves utilities eagerly so no new emit needed — pure regression coverage.

The validation suite's as const satisfies type guard catches drift between the suite and the adapter describe blocks. Each block's "all cases ran" counter test catches forgotten it() registrations.

Renderer-side architecture: composite emits propagate a CodeNS sentinel from any unsupported leaf upward through the existing compile pass; the renderer's dangling-dep cascade then drops any entry whose recorded deps weren't emitted. Replaces an earlier O(M·S) subtreeFullySupported pre-walk; runtime behavior is unchanged (unsupported types silently absent, createValidateFn-side noop fallback () => true handles the cache miss). See ts-go-runtypes/internal/cachegen/typefunctions/codetype.goCodeNS for the full contract.

Out of scope for validate (and tracked separately, will live in the validation-constraints library):

  • Number brand types (int / uint8 / Range<a, b> / …)
  • String-mapping constraint forms (Uppercase<string> as a generic constraint; the literal-collapsed forms work today via the standard literal-equality check)

strictTypes validate option — not wired yet

The RunTypeOptions.strictTypes knob (reject objects carrying unknown/extra properties inside isType) is the one validate knob not wired yet. The object emit already documents the hook point — see the emitObjectValidate comment in ts-go-runtypes/internal/cachegen/typefunctions/validate.go ("lands when a caller needs it").

  • Workaround today: compose the two existing factories — createValidateFn<T>() && !createHasUnknownKeysFn<T>() (or createUnknownKeyErrorsFn<T>() when error records are wanted). Same semantics, two cache lookups instead of one fused body.
  • Approach when needed: ValidateOptions is registry-driven, so add strictTypes to the registry (it participates in the fnHash via CompTimeFnArgs like noLiterals / noIsArrayCheck), and have emitObjectValidate append the unknown-keys check (the hasUnknownKeys emit already exists per kind) when the option is set. No protocol change.

Reflection Type variants not yet projected

  • infer (kind 34) — infer T placeholder. Only meaningful inside unresolved conditional types, which tsgo eagerly resolves; would only appear if we add an op that returns the unresolved form.
  • rest (kind 29) outside tuples — function rest parameters. Currently marked with a flags entry; the dedicated rest Type variant comes later.
  • enumMember (kind 28) standalone — we emit enum.values but not per-member TypeEnumMember nodes. Add when needed.

JSON shape — known limitations and how we handle them

LimitationCauseHandling
Cyclic types in raw JSONJSON has no cycle support.Refs are sentinels ({kind: -1, id: "<hash>"}) in JSON; the generated .ts artifact resolves cycles via direct const assignment in the footer. JSON-only consumers walk the table to re-knot.
parent back-referencesSame — JSON has no cycles.Not emitted at all. Canonical nodes are shared singletons (one per structural id) so a stored parent would be wrong for any node with multiple parents. Consumers that need a parent link build it themselves while walking the graph from a known root.
Symbol-keyed property namesJSON has no symbol type.Emit synthetic @@<name> strings + flags: ["symbol"]. Round-tripping symbol identity would require a runtime symbol registry — out of scope.
bigint literal valuesJSON numbers lose precision past 2⁵³.Emit as a string with flags: ["bigint"]; the .ts footer re-hydrates with BigInt(...). JSON consumers do the same.
regexp literal valuesJSON has no RegExp type.Emit {regexp: {source, flags}}; the .ts footer re-hydrates with new RegExp(source, flags). JSON consumers do the same.
symbol literal valuesJSON has no symbol type.Emit description string; the .ts footer re-hydrates with Symbol(desc). Identity is not preserved — same caveat as symbol-keyed names.

Union discriminator wire shape — unionDiscriminators[]

The reference codegen path for discriminated unions consumes a FlattenedProp struct per object member (ref: packages/run-types/src/nodes/collection/unionDiscriminator.ts). Our wire stores only the strictly-new field — a ref to the discriminator property — on the union node:

// On a TypeUnion RunType:
unionDiscriminators?: (RunType | null | undefined)[];
// Parallel to `safeUnionChildren`. Entry i is a ref to the discriminator
// property within safeUnionChildren[i]; null/undefined for non-object
// slots (simple / any). Absent when neither detection pass finds a
// usable discriminator.

Everything else the FlattenedProp carries is reconstructible from the surrounding wire shape. Consumers call flattenUnionDiscriminators from @ts-runtypes/core to materialise the full per-member struct in one pass — it pairs each safeUnionChildren[i] with the parallel unionDiscriminators[i] and resolves the property's typeID via prop.child.id.

Rationale: the wire format leans on dedup/minimality elsewhere; carrying unionItem / unionIndex / typeID directly would duplicate data already on the wire (safeUnionChildren[i], the index itself, and the property's child ref id respectively). The detection passes (shared-name + unique-prop fallback) live on the Go side (ts-go-runtypes/internal/cachegen/runtype/union_safeorder.go); both write into this single slot, scoped to the parent union — a property node shared between two unions is independently classified for each parent.

compiledName in the reference struct is a codegen-time local variable name; it isn't wire data and is allocated by the consumer when emitting JS.

Binary serialization — function-params router conveniences

The binary spec carries two features that the JSON family doesn't need and that we have intentionally not ported:

FeatureWhat it doesReference location
All function params are optionalEvery top-level tuple slot becomes optional at the binary protocol level — a caller can transmit ['hello', undefined, undefined] for a (a, b, c) function and the receiver decodes the bits that are set. Enables partial-payload RPC.packages/run-types/src/rtCompilers/binary/binarySpec/13BinaryAllParamsOptional.spec.ts (10 cases), driven by rt.createRTParamsFunction(toBinary) / createSerializationParamsFn in binaryHelpers.ts.
paramsSliceSkips the leading N params of a function tuple before serialising. Used by a router to strip an injected context arg ((ctx, a, b) → wire shape only (a, b)).Same file, plus the slice function params test in 06BinaryFunctions.spec.ts, exposed via the 2nd argument of createSerializationParamsFn(rt, sliceStart).

Both are router-layer conveniences, not generic type-system features. RunTypes is the latter, so neither has a use case in the current public API surface.

Why we don't re-introduce SubKindParams to the protocol to support these:

Every other RT generator (validate, getValidationErrors, prepareForJson, restoreFromJson, stringifyJson, prepareForJsonSafe, prepareForJsonSafePreserve, hasUnknownKeys, stripUnknownKeys, unknownKeyErrors, unknownKeysToUndefined, unknownKeysToUndefinedWire) consumes function parameters via the TS-native Parameters<typeof fn> slice — which lowers to a plain tuple at the type-checker layer. None of them carry or read a "this tuple is function params" marker. Adding SubKindParams for toBinary / fromBinary alone would force every other family to either ignore it (wasted protocol bytes) or branch on it (asymmetric code paths across the RT family). Both are worse than the current uniformity, which is: Parameters<typeof fn> is a tuple, period; binary handles it like any other tuple.

Migration path if we ever need these features:

Surface them as caller-driven options on the binary entry points, not as protocol-level type variants:

createBinaryEncoderFn<T>(val?, options?: {allOptional?: boolean; sliceStart?: number}, id?)
createBinaryDecoderFn<T>(val?, options?: {allOptional?: boolean; sliceStart?: number}, id?)

emitTupleToBinary in ts-go-runtypes/internal/cachegen/typefunctions/binary_to.go currently decides each member's bitmap slot from resolved.Optional alone. Adding allOptional means OR-ing a per-request flag into that decision (flag || resolved.Optional) and threading it from the encoder/decoder factory; sliceStart means starting the bitmap loop at the supplied offset and skipping the leading children at compile time. Both are additive factory options — no protocol-level hook exists today.

The corresponding 10 13BinaryAllParamsOptional tests + the slice function params test become a new test/adapters/binaryParams.test.ts file (small enough to hand-write — they're all variations on the same idea, no shared suite needed) once the API is in place.


Compiler / resolver features not yet shipped

  • Pre-process build mode (ts-runtypes build --out .runtypes/) for bundler-agnostic integration (Bun, SWC, plain tsgo). The binary already supports --out-json / --out-modules; the missing piece is a one-shot CLI subcommand that walks a project's source files itself instead of relying on the plugin to drive scanFiles.
  • Babel adapter plugin. The unplugin factory already provides the Vite / Rollup / webpack / Rspack / esbuild adapters (the rewrite lives in the Go transform package, so each adapter is thin glue); a Babel adapter is the remaining target. Defer until there's user demand.
  • Vendored shim (drop the tsgolint submodule entirely, regenerate the shim ourselves via tools/gen_shims). Cleaner git clone && go build. Do once the API shape stabilises.
  • Incremental Program rebuild (resolver latency) — module-level HMR already works: per-entry virtual modules are content-addressed (immutable, never invalidated) and the single mutable virtual:rt/runtypes.js bundle is invalidated on addedRunTypes in the plugin's handleHotUpdate. The remaining optimization is Go-side — dispatchSetSources rebuilds the full inferred Program on every edit via program.NewInferred; wiring tsgo's Program.UpdateProgram (already available in the vendored checker) for incremental rebinding would skip the full re-typecheck. Correctness is fine today; this is purely latency.
  • Concurrency: the runtype serializer in ts-go-runtypes/internal/cachegen/runtype/serialize.go is single-threaded by design; the resolver holds one checker. Multi-checker fan-out (one per CPU, like tsgolint's linter) is a later concern.
  • Lint follow-ups (the OXlint/ESLint plugin itself SHIPPED): an LSP sink (@ts-runtypes/devtools/lsp) reusing the same transport-agnostic routing layer for editors without oxc; full-compilerOptions lint fidelity SHIPPED — the per-file pass parses the project tsconfig once per session and adopts the FULL compilerOptions wholesale, so lib-sensitive codes like TMP001 match a build, and a broken config is a loud CFG001 instead of a silent fallback; GE001 (mirror location drift) through the protocol pass once the resolver can read the project's enrich-dir config; oxlint suggestions (non-auto fixes) for carcass removal.
  • Class serializer — open-world polymorphism (SHIPPED so far — optional serialize / optional deserialize for zero-arg classes / registerClassSerializer(cls, handler?) keyed by the injected type id plus a class-name fallback lane, so one registration covers every generic instantiation (same-name/different-class collisions degrade that name to exact-id-only with a warning) / JSON + binary union reconstruction). Union reconstruction uses the union's existing numeric member index (the [idx, value] envelope binary already uses), NOT a synthetic rt$classID string: named class members route through the flat union's per-member (atomic) dispatch, guarded on encode by v instanceof cs.cls with a structural fallback, and the decoder's existing index dispatch runs the class restore wrapper (prerequisite: the class name is folded into the plain-class structural id so two same-shape classes stay distinct). The registry is a runtime lookup (client and server can register different serializers against one build), keyed by the class's type id plus the class-name fallback lane. The remaining follow-up is Phase 2 — open-world polymorphism: a field typed as a base class / interface reconstructs whatever registered subclass the wire names, where the candidate set is not statically known. Needs an open dispatch that carries the concrete subclass's type id on the wire (the closed-set union path today keys off the statically-known member ids).
  • Class serializer — custom serialize owning an arbitrary JSON wire shape. A custom serialize today must stay within the class's declared object shape on the JSON path (a string / renamed-key shape breaks the structural unknownKeysToUndefinedWire pre-pass in the JSON decoder). Binary is unaffected. The repro is classSerializer.test.ts, which pins the supported shape.

Open questions parked for later

  • Value-first format/constraint definitions (object({...}) builder API + InferType<> alongside the type-first FormatString<…>): a value-first authoring surface — Zod/TypeBox-style per-type builders (string({maxLength:50}), number({min:0}), …) composed by object({...}), the type derived via plain type mapping (no TS infer), coexisting with today's type reflection over one shared engine. Shipped (@ts-runtypes/core/builders): all leaf-format builders — string() / number() / date() / bigint() / boolean() + the 6 orderable temporal types under a lowercase temporal namespace (temporal.instant()temporal.plainYearMonth(), mirroring the Temporal.X API) — over the type channel (builders now return the brand directly, so createValidateFn<typeof Model>() — see the RunType-construct / marker refactor below), with inline patterns (string({pattern: {source, flags}}) or a registerFormatPattern value — mockSamples optional, the build generates a pool from the regex when absent, fresh per build unless a literal mock.seed on createMockDataFn pins it; a bare /regex/ VALUE is rejected since its literals cannot be recovered) and property modifiers via propMod({optional?, readonly?}, field) / the optional(field) shortcut (→ key?: / readonly key:). Each value-first field converges on the same structural id as its type-first equivalent (one engine, two front doors); each builder types its own params arg so cross-family misuse errors locally at the call (no exclusive-union machinery needed). Regex needed one small additive Go change (recover the literal from the property declaration the homomorphic mapped type preserves), not a value-AST front-end. Deliberate boundary (keeps it from becoming a worse TypeBox): the value DSL owns leaf formats onlycomposition (array / union / tuple / nullable / nesting) stays in the type channel, where it composes for free (ModelType<typeof M>[], ModelType<typeof A> | null, {x: ModelType<typeof M>} all work today, no new API). A recursive value-config DSL ({type:'array', of:…}) is not pursued — it reinvents the TS type system as values and needs infer. That boundary holds. A recursive value-config dialect ({type:'array', of:…}) is not part of the product: the two authoring forms are plain types and builders, both resolving through the type channel to one shared engine — no runtime AST interpreter, no second engine. The value-first surface keeps its leaf-formats-only rule unchanged; composition still belongs to the type channel. RunType-construct / marker refactor — shipped (Tiers 1–3): each builder returns its branded format type directly (so typeof Model IS the model type, no ModelType<…> hop on the forward path), is an injectable marker resolving to the live RunType node, and the inverse reflectModel<T>() and the ModelType / ModelConfigOf<T> config↔type bridge were later REMOVED (the RunType node + InferType<T> already carry the model), and the surface was reorganized under @ts-runtypes/core/builders (atomic.ts / compose.ts / utility.ts / static.ts), renamed from @ts-runtypes/core/schema at 0.11.0. The ./schema subpath stays as a deprecated alias resolving to the same module, and is REMOVED at 1.0. A full value call form (Model.validate(x)) is still parked. Temporal value-first fields require ESNext.Temporal in the consumer's lib (same rule as the type-first Temporal formats).
  • Label-capable type builders — shipped (docs/done/label-capable-builders.md): tuple element labels and function parameter names are id-relevant, and plain RT.tuple / RT.func groups of bare run-types express unlabeled/unnamed shapes (that divergence stays pinned in callableBuilder.test.ts). The labeled forms wrap each element in RT.slot(label, rt)RT.tuple({required: [RT.slot('x', RT.number()), RT.slot('y', RT.number())]}), optional/rest slots included, RT.func({params: [RT.slot('event', …)], ret}) for named params — carrying the labels on the __rtLabels sentinel, so the value-first ids converge with the labeled type-first twins (pinned in labeledSlots.test.ts + labeled_builders_test.go, both marker shapes). The spec's original object-literal spelling ({x: RT.number()}, key order = slot order) was CUT during implementation: the checker keeps keyof unions sorted by internal type id (tsgo addTypeToUnion inserts via CompareTypes binary search), so object key order is unobservable at the type level and {w, h} projected [h, w] — slots are the spec's own pairs fallback, made explicit. The builders DO take an object, but its keys name the GROUPS (required / optional / rest, params / ret — a fixed set read by name, never a slot list), which is order-free; that group form replaced the positional arity ladder outright (docs/done/tuple-options-object-form.md).
  • Recursive type aliases (type List = { head: number; tail: List | null }): the id-table dedup handles them at the data layer, the .ts artifact's footer re-knots cycles. Already exercised by the circular fixtures F29–F34 — including F30's recursive alias type CuArray = (CuArray | Date | number | string)[]; an object-shaped List alias fixture is the only remaining shape worth pinning down.
  • Conditional and mapped types: tsgo resolves these to concrete types at the call site; we emit the resolved form. We lose the original conditional/mapped expression. If runtypes ever needs the unresolved form, record it in flags as a string snapshot of the source text.
  • Unions of literals vs widened primitive: tsgo aggressively widens ("a" | "b" becomes string in many contexts). Document any divergence from parser-level behaviour as fixtures surface it.
  • Generic type parameters at the declaration site (vs at the use site): TypeTypeParameter represents <T> unbound. We always operate on resolved instantiations. If a consumer needs the unbound form, expose resolveDeclaration as a separate op.
  • createValidateFn / createGetValidationErrorsFn return type and naming: today these silently drop non-serialisable members (functions, methods, symbols, symbol-keyed properties) from the validated shape and emit a Warning. Users sometimes expect the validator to enforce the full TS type. Two future directions worth discussing before changing — current callers depend on the existing silent-drop semantics:
    • Refine the return type to ValidateFn<DataOnly<T>> where DataOnly<T> is a TS-level mapped type that strips non-serialisable members. The validator's signature would then state the truthful guarantee: "this function validates the serializable projection of T, not T itself." Caller-side IDE feedback improves with no runtime change.
    • Rename createValidateFncreateIsDataType (explicit naming). Optionally also introduce a stricter createIsFullType that errors instead of dropping for non-serialisable members. The two functions cover the two use cases: validating wire data (current behaviour, renamed for clarity) vs asserting a full TS type at a boundary that owns the values (new).
    • See CLAUDE.md "validate contract" for the current semantic.

Conventions for adding to this file

  • A row in "Known gaps" should always include a concrete approach. If we can't think of one, escalate to "Compiler / resolver features not yet shipped" or "Open questions".
  • A row in "Compile-time only — what we will never capture" is a permanent design decision, not a deferral. Don't promote out of it without a redesign discussion.
  • Implemented work belongs in the status snapshot, not in a pending list — prune as you ship.