@byteslayer/ts-check
v0.3.1
Published
Simple runtime Typecheck generator for TypeScript, write your Types once and be safe everywhere.
Readme
ts-check
Simple runtime Typecheck generator for TypeScript — write your types once and be safe everywhere.
ts-check turns your existing TypeScript types into runtime checks. Annotate a
function, and ts-check reads its real parameter and return types (via the actual
TypeScript compiler, not a re-implementation) and generates if guards that
throw a TypeCheckError when a call doesn't match — no schema to write, no
validator to keep in sync by hand.
/** @Check */
function total(order: Order, pricePerUnit: number): number {
return order.items.reduce((sum, i) => sum + i.quantity * pricePerUnit, 0);
}
total({ id: "o2", items: [{ sku: "x", quantity: "two" }] }, 5);
// TypeCheckError: total(): argument "order" expected Order, got ObjectThat check didn't exist in your source. ts-check generated it at build time from
the Order type, and wove it into the compiled output.
Why
TypeScript types disappear at compile time. A function signature is a
promise to the compiler, not a guarantee at runtime — a bad API response,
JSON.parse, or a caller who ignored a type error will walk straight past
order: Order and into your function body unchecked.
The usual fix is a parallel runtime schema (Zod, io-ts, ...) that you write by hand and keep in sync with the type yourself. ts-check skips the duplication: it derives the check from the type you already wrote, at build time, so it can't drift out of sync — there's nothing second to maintain.
How it plugs in
ts-check is a ts-patch plugin — it hooks
into real tsc, rather than replacing it. Your existing build (tsc,
tsc -b, --watch, CI scripts, whatever you already run) keeps working;
you're adding a plugin entry to tsconfig.json, not swapping compilers.
That gets you everything tsc already does — incremental builds, project
references, composing with other tsc plugins — for free.
Install
npm install --save-dev @byteslayer/ts-checkts-patch and typescript come along
as direct dependencies — nothing else to install. That pulls in ts-patch
3.x, which targets TypeScript 5.x; for a TypeScript 6.x project, override
ts-patch to ^4.0.0 (e.g. via npm's overrides) to match.
Usage
Add a plugins entry under compilerOptions pointing at @byteslayer/ts-check/transformer:
{
"compilerOptions": {
// ...your normal options...
"plugins": [
{ "transform": "@byteslayer/ts-check/transformer", "mode": "explicit", "include": ["*"] }
]
}
}Then build with tspc — ts-patch's drop-in tsc, no persistent patching
needed — instead of plain tsc:
tspc -p tsconfig.jsonPlain unpatched tsc silently ignores the plugins entry and produces
unchecked output — you must build with tspc, or run ts-patch install
once to persistently patch your project's typescript install so plain
tsc/tsc -b picks the plugin up too (see ts-patch's own docs for that
route).
| Key | Values | Meaning |
|---|---|---|
| mode | "explicit" (default) | "implicit" | explicit: only functions tagged /** @Check */ get checks. implicit: every checkable function in an included file gets checks, no tag needed. |
| include | glob array, default ["*"] | Which files (relative to the tsconfig's directory) ts-check instruments. Supports *, **, ? — e.g. ["src/**/*.ts"]. Files outside this list compile normally, untouched. |
| exclude | glob array, default [] | Carves files back out of include, same glob syntax — the reverse of include. A file must match include and not match exclude to be instrumented. |
| use | object, see below | Per-feature on/off switches. Every feature defaults to true; list only the ones you want to turn off. |
| options.generateChecksForInferredTypes | boolean, default true | When false, only parameters/return types with an explicit type annotation are checked — inferred ones are skipped. |
Turning off individual features (use)
use is a single object with one boolean key per kind of check ts-check can
generate. Every key defaults to true — you only need to list the ones you
want disabled:
{
"transform": "@byteslayer/ts-check/transformer",
"mode": "implicit",
"use": {
"dates": false, // stop checking Date instances
"generics": false, // skip checking a generic against its constraint
"yieldChecks": false // stop checking generator `yield` values
}
}| Key | Governs |
|---|---|
| string, number, boolean, bigint, symbol, null, undefined, object, never | The primitive/keyword types. |
| stringLiterals, numberLiterals, booleanLiterals, bigintLiterals, templateLiterals | Literal and template-literal types ("a", 42, `id-${number}`). |
| arrays, tuples | T[]/Array<T> and fixed/variadic tuples, independently. |
| objects, indexSignatures | Structural property checks on object/interface types, and { [key: string]: V } / { [key: number]: V } index signatures. |
| unions, intersections | A \| B and A & B. |
| classes | instanceof checks for a class declared in, or safely referenceable from, the file. |
| dates, regExps, collections, promises, errors | The known global instance types — Date; RegExp; Map/Set/WeakMap/WeakSet; Promise; Error. |
| functionTypes | Whether a function-typed value is checked for being a function at all. |
| generics | Checking an unresolved type parameter against its extends constraint. |
| parameterChecks | Parameter guards at the top of a checkable function's body. |
| returnValueChecks | return <expr>; checks in an ordinary (non-async, non-generator) function. |
| asyncFunctionReturnChecks | return <expr>; in an async function, checked against the awaited type. |
| generatorReturnChecks | return <expr>; in a generator, checked against its TReturn. |
| yieldChecks | yield <expr>; in a (non-async) generator. |
| asyncGeneratorYieldChecks | yield <expr>; in an async function*, checked against the awaited type. |
| redundantParamGuardElimination | Dropping a non-exported function's parameter guard when every call to it in the file provably already supplies an already-validated value. See "Redundant check elimination" below. |
Disabling a feature never falls back to a stricter check — it degrades to
"no check" (the same permissive treatment any/unknown already get), so a
disabled feature can only make ts-check quieter, never break a call that
worked before. classes is the one exception: turning it off only disables
the instanceof optimization — a class instance that isn't a safe
instanceof target already falls back to a structural check (see
COVERAGE.md), and classes: false falls back the same way instead of
skipping the value entirely. redundantParamGuardElimination inverts this:
it's purely an optimization, so turning it off is what makes output more
verbose — it never changes which bad calls get caught either way.
Redundant check elimination
ts-check drops a parameter guard when it can prove, from the rest of the file, that the check is redundant:
function process(order: Order) {
return charge(order); // order already validated by process's own guard
}
function charge(order: Order) { /* ... */ } // charge's guard on `order` is droppedcharge isn't exported and is only ever called with order — the exact
value process's own guard already validated — so charge's guard would
just recheck something already known to be true. ts-check builds a
dependency graph across every checkable function in the file to work this
out, and only elides a guard when it's certain: the callee must be a
non-exported function whose address is never taken (only ever called
directly), every call site must pass the same identifier, unmodified,
from a parameter that's itself already checked with the exact same type,
and a cycle of functions trusting only each other — with no real check
backing any of them — keeps every guard in it rather than eliminating
based on nothing. Any doubt keeps the guard; this never changes which bad
calls get caught, only how many times an already-proven-valid value gets
re-validated. Turn it off (see the use table above) if you'd rather see
every guard generated as-is.
No plugins entry (or none naming @byteslayer/ts-check/transformer) means ts-check does
nothing — the file compiles exactly as plain tsc would, zero overhead.
Marking what to check
In explicit mode (the default), tag a function with the @Check JSDoc
comment — it's plain, always-valid TypeScript syntax, no import required:
/** @Check */
function greet(name: string) { ... }
/** @Check */
const double = (n: number) => n * 2;
class OrderService {
/** @Check */
place(order: Order) { ... }
// no tag -> stays unchecked
cancel(id: string) { ... }
}In implicit mode every function, method, constructor, and accessor in an
included file is checked automatically — no tags needed.
Running the checked code
ts-check's build produces ordinary JS. A failed check throws a TypeError
(named TypeCheckError) with a message naming the function, the offending
parameter (or "return value"), the expected type, and a short description
of what was actually received:
TypeCheckError: total(): argument "order" expected Order, got Object
TypeCheckError: total(): return value expected number, got stringFramework integrations
The setup above works for a plain tsc/tspc build. Bundler-based
frameworks don't run one: Vite (and everything built on it — Nuxt,
SvelteKit, Astro) transforms .ts/.tsx with esbuild during dev and build,
which only strips types and never runs tsc's transformer plugins; Next.js's
default pipeline (SWC) does the same. Either way, compilerOptions.plugins
in your tsconfig is silently ignored for the actual app bundle — you'd only
see checks from a separate, standalone type-check step. These integrations
route the matched files through a real ts.Program emit (via ts-patch's
patched compiler) first, so the transformer actually runs.
Plain tsc backends (Express, NestJS, ...) — no integration needed
If your build is already just tsc/ts-patch-driven — a plain Node/Express
backend, or NestJS's default tsc builder ("builder": "tsc" in
nest-cli.json, the default) — you're already covered by Install
and Usage above; nothing framework-specific to add. If you want
plain tsc/tsc -b/watch mode to pick the plugin up too, not just tspc,
run ts-patch install once (see Usage) to persistently patch your project's
typescript install. NestJS's "builder": "swc" option isn't covered —
same reason as esbuild/SWC everywhere else: it never runs tsc's transformer
plugins.
Nuxt, SvelteKit, Astro, React, Vue, Preact, Solid, Remix, React Router (Vite)
// nuxt.config.ts
import { nuxtTsCheckPlugin } from "@byteslayer/ts-check/vite";
export default defineNuxtConfig({
vite: { plugins: [nuxtTsCheckPlugin(import.meta.dirname)] },
});// svelte.config.js / vite.config.ts
import { svelteKitTsCheckPlugin } from "@byteslayer/ts-check/vite";
plugins: [svelteKitTsCheckPlugin(import.meta.dirname)];// astro.config.mjs
import { astroTsCheckPlugin } from "@byteslayer/ts-check/vite";
export default defineConfig({
vite: { plugins: [astroTsCheckPlugin(new URL(".", import.meta.url).pathname)] },
});For a plain Vite-based app — React, Vue, Preact, Solid, React Router v7
(framework mode), or Remix in Vite mode (remix vite:build, the current
default) — the same generic plugin applies, exported under a per-framework
name purely for discoverability (reactTsCheckPlugin, vueTsCheckPlugin,
preactTsCheckPlugin, solidTsCheckPlugin, reactRouterTsCheckPlugin,
remixTsCheckPlugin — all identical, all default to your project's own
tsconfig.json):
// vite.config.ts
import { defineConfig } from "vite";
import { reactTsCheckPlugin } from "@byteslayer/ts-check/vite";
export default defineConfig({
plugins: [reactTsCheckPlugin(import.meta.dirname)],
});Classic Remix's pre-Vite esbuild compiler isn't covered — same story as Next.js's Turbopack below.
Each preset just points the generic tsCheckVitePlugin({ rootDir, tsconfigPath })
at the tsconfig you'd actually add a plugins entry to (Nuxt: .nuxt/tsconfig.app.json,
the one Nuxt's own typescript.tsConfig merges into; SvelteKit and Astro:
your project's own root tsconfig.json by default — SvelteKit's generated
.svelte-kit/tsconfig.json is overwritten by svelte-kit sync and isn't
meant to be edited directly, so point at the root one it extends, same as
you already do for your own compiler options). Pass a different
tsconfigPath if your setup differs. Only plain .ts/.tsx files are
covered; .vue/.svelte/.astro component <script> blocks go through
their own framework-specific pipeline.
Next.js (webpack)
// next.config.js
const { withTsCheck } = require("@byteslayer/ts-check/webpack");
module.exports = withTsCheck({
// ...your existing config
});Requires webpack mode (next build / next dev, no --turbo) — Turbopack
doesn't support custom webpack loaders. Pass { tsconfigPath: "..." } as a
second argument if your tsconfig isn't at the project root.
Not supported: Angular, Ember
Verified against a real ng build and deliberately not shipped: Angular's
esbuild-based CLI build doesn't route through the ts-patch-patched
typescript package the way plain tsc and Vite's esbuild-fallback do —
@angular/compiler-cli's own program wrapper (NgtscProgram) sidesteps it,
so ts-patch install alone silently does nothing, and there's no equivalent
of a Vite transform hook or webpack loader to fall back to without
integrating against Angular's own compiler internals. Ember's build
(Broccoli) has no ts.Program-based hook at all — closer to Angular's
problem than to Vite's. Both would need dedicated, framework-specific work
this package doesn't do today; support isn't claimed for either.
What gets checked
Parameters and return values of any checkable function — function
declarations/expressions, arrow functions, class methods, constructors, and
get/set accessors. ts-check works from the TypeScript compiler's own resolved
types, so it covers primitives, literals, template literal types, arrays/tuples,
objects, interfaces, unions/intersections (including discriminated unions), enums,
classes (via instanceof), recursive/self-referential types, generics
instantiated with concrete types, and the standard utility types
(Partial, Pick, Omit, Record, ...).
async function return values are checked against the awaited (resolved)
type, not the declared Promise<T> — a failed check surfaces as a rejected
promise rather than a synchronous throw, same as any other async error.
Generator functions get the same treatment for yield <expr>; and
return <expr>;, when used as their own statements — including
async function* generators, whose yielded values the JS runtime itself
awaits before a caller sees them, checked the same resolved-value way as an
async function's return.
It deliberately skips what can't be soundly checked: generic type
parameters that are erased by the time the code runs, void/any/unknown,
an async generator's return value specifically (unlike its yield, the
runtime doesn't await it, and TypeScript's own types don't guarantee its
shape either — no sound value to check against), yield used as a
sub-expression or yield* delegation, and a function value's own signature
(only that it is a function).
See COVERAGE.md for the full feature-by-feature matrix — every row backed by a passing test.
Examples
examples/ has runnable, self-contained projects, each with its own
package.json/tsconfig.json:
| Example | Demonstrates |
|---|---|
| examples/demo | A tour of common types, generics, and a small "order total" scenario with a checked function catching a bad call |
| examples/explicit | mode: "explicit" — only @Check-tagged functions are instrumented |
| examples/implicit | mode: "implicit" — every function is instrumented automatically. Includes feature-tour.ts (a sweep across nearly every checkable TS/JS construct: primitives, literals, template literal types, tuples, objects, symbol-keyed properties, unions, enums, classes, generics, utility types, known globals, every function form) and async-tour.ts (async functions, generators, async generators) — both call every generated check with a good and a bad value |
| examples/coverage | A denser sampler across more of the type-system surface in COVERAGE.md |
Run one, from the repo root:
bun run build # compiles src/tsPatchPlugin.ts to dist/, for Node to require
cd examples/demo
bun install # picks up "@byteslayer/ts-check": "file:../.." from package.json
bun run build # tspc -p tsconfig.json
bun run start # runs the compiled outputHow it works
src/analyze.tswalks the program before emit, using the realts.TypeCheckerto resolve every checkable parameter/return type and build the corresponding validator expressions (src/typeToValidator.ts). This has to happen in its own pass, before emit — calling the checker from inside a transformer during emit crashes TypeScript internally.src/transformer.tsis a pure structural transform: given the precomputed analysis, it splices parameter guards in at the top of each function body and rewrites everyreturnto check its value first, with zero further checker calls.src/runtimePrelude.tssupplies the small__typeChecksruntime helper (primitive predicates, error formatting) injected once per file that needs it.src/tsPatchPlugin.tsis the plugin entry point ts-patch calls:(program, pluginConfig) => TransformerFactory, invoked once per compilation with theProgramts-patch already built. No reimplemented build driver — realtscdoes everything else (parsing, diagnostics, incremental caching, emit).
@Check-tag detection (analyze.ts's nodeHasCheckTag) scans leading
comment text directly, rather than going through ts.getJSDocTags/
node.jsDoc. ts-patch's live compiler (tspc) parses source with
JSDoc-to-AST association skipped for performance
(JSDocParsingMode.ParseNone), which leaves the JSDoc AST empty even
though the comment is right there in the source — scanning text sidesteps
that entirely.
Development
bun install
bun test # tests across src/analyze, transformer, glob, and generated-code behavior
bun run build # tsc -p tsconfig.build.json (compiles src/tsPatchPlugin.ts + deps to dist/)Tests run the real pipeline (analyze → transform → emit) in memory via
test/harness.ts, then assert on both the generated code and its actual
runtime behavior — not just that some code was emitted.
Limitations
- Structural, not exact. Like TypeScript itself, ts-check checks that required shape is present — it doesn't reject excess properties.
readonlyis unenforceable at runtime — same as in TypeScript, compile-time only.- A function-typed value is only checked for being a function — its own parameter/return signature can't be verified without calling it.
- Generic type parameters are erased. Anything that depends on an
unconstrained
<T>can't be checked; the parts of the type that don't depend onTstill are. void/any/unknownare intentionally not checked — see COVERAGE.md for why.- A failed
async functioncheck (or anasync function*'syieldcheck) rejects the returned promise instead of throwing synchronously — the correct shape for an async error, but worth knowing if you're used to every other ts-check failure throwing immediately. - An
async function*'sreturnvalue is never checked — itsyields are, but the runtime doesn't await the returned value the way it awaits a yielded one, and TypeScript's own types don't guarantee its shape either; see COVERAGE.md for the full reasoning. yieldused as a sub-expression andyield*delegation are not checked — see COVERAGE.md for why.- Template literal type numbers use decimal notation only — a
`${number}`placeholder won't match"NaN","Infinity", or exponential notation, even though those are valid runtime values of typenumber. - Depends on TypeScript's internal transformer API, which is not guaranteed stable across major TS versions, and on ts-patch's own compatibility with your TypeScript version — ts-check supports both TS5 and TS6, via ts-patch 3.x (TypeScript 5.x) and ts-patch 4.x (TypeScript 6.x) respectively; match the major version to what your project uses.
License
MIT
