Skip to content

Repository files navigation

Typical

Typical makes TypeScript type safe at runtime with no changes to your code.

It transforms your code to inject runtime validation based on your existing type annotations. With source maps, so errors point to the right lines in your original code.

Why?

  • Less need for zod, yup, ajv, or other runtime validation libraries - your types are already validated automatically. If you can express it in TypeScript, Typical can validate it at runtime.
  • Protects against data leaks via JSON.stringify by ensuring only properties defined in your types are included
  • Catches type mismatches at runtime that TypeScript can't catch at compile time (API responses, JSON parsing, un-typed/badly-typed libraries, vibe-coding coworkers etc.)

Features

  • Validation of function parameters and return types
  • Safe JSON.parse with type validation
  • Safe JSON.stringify that only includes defined properties
  • Validation of type casts (as Type)
  • Configurable include/exclude patterns

Example

This code runs without errors in normal TypeScript, but Typical catches the invalid data:

interfaceUser{name: string;email: `${string}@${string}`;}// This will throw - email doesn't match the template literal typeconstuser=JSON.parse('{"name":"Alice","email":"not-an-email"}')asUser;

Usage Options

Choose the integration that fits your workflow:

MethodBest ForPackage
ESM LoaderNode.js scripts, development@elliots/typical
ttsxQuick scripts with tsx@elliots/typical + tsx
Bun PluginBun projects@elliots/bun-plugin-typical
React Native / ExpoReact Native apps@elliots/metro-transformer-typical
Vite/Webpack/etcFrontend apps, bundled projects@elliots/unplugin-typical
tsc PluginPure TypeScript compilation@elliots/typical-tsc-plugin

Node.js (ESM Loader)

The simplest way to run TypeScript with Typical validation.

npm add @elliots/typical
node --import @elliots/typical/esm src/index.ts

Add to package.json scripts:

{
"scripts": {
"start": "node --import @elliots/typical/esm src/index.ts"
}
}

ttsx (tsx wrapper)

A convenience wrapper that combines tsx with Typical.

npm add @elliots/typical tsx
npx ttsx script.ts

Or install globally:

npm add -g @elliots/typical tsx
ttsx script.ts

Note:tsx must be installed separately. The ttsx command is a thin wrapper that runs tsx with the Typical ESM loader.


Bun

bun add @elliots/bun-plugin-typical

Create bunfig.toml:

preload = ["./preload.ts"]

Create preload.ts:

import{typicalPlugin}from"@elliots/bun-plugin-typical";Bun.plugin(typicalPlugin());

Then run:

bun run src/index.ts

React Native / Expo

npm add @elliots/metro-transformer-typical

Update your metro.config.js:

// metro.config.jsconst{ getDefaultConfig }=require("expo/metro-config");const{ withTypical }=require("@elliots/metro-transformer-typical");module.exports=withTypical(getDefaultConfig(__dirname));

For vanilla React Native (without Expo):

// metro.config.jsconst{ getDefaultConfig }=require("@react-native/metro-config");const{ withTypical }=require("@elliots/metro-transformer-typical");module.exports=withTypical(getDefaultConfig(__dirname));

With options:

module.exports=withTypical(getDefaultConfig(__dirname),{typical: {validateCasts: true,},});

Bundlers (Vite, Webpack, Rollup, esbuild)

npm add @elliots/unplugin-typical

Vite

// vite.config.tsimportTypicalfrom"@elliots/unplugin-typical/vite";exportdefaultdefineConfig({plugins: [Typical()],});

Webpack

// webpack.config.jsconstTypical=require("@elliots/unplugin-typical/webpack").default;module.exports={plugins: [Typical()],};

Rollup

// rollup.config.jsimportTypicalfrom"@elliots/unplugin-typical/rollup";exportdefault{plugins: [Typical()],};

esbuild

import{build}from"esbuild";importTypicalfrom"@elliots/unplugin-typical/esbuild";build({plugins: [Typical()],});

Rolldown

// rolldown.config.tsimportTypicalfrom"@elliots/unplugin-typical/rolldown";exportdefault{plugins: [Typical()],};

Farm

// farm.config.tsimportTypicalfrom"@elliots/unplugin-typical/farm";exportdefault{plugins: [Typical()],};

Rspack

// rspack.config.tsimportTypicalfrom"@elliots/unplugin-typical/rspack";exportdefault{plugins: [Typical()],};

TypeScript Compiler (tsc)

For projects that compile with tsc directly using ts-patch.

npm add @elliots/typical-tsc-plugin ts-patch

Option 1: ttsc (auto-injects plugin)

The ttsc command automatically injects the plugin - no config needed:

npx ttsc

Add to package.json:

{
"scripts": {
"build": "ttsc"
}
}

Option 2: Manual tsconfig.json

Add to your tsconfig.json:

{
"compilerOptions": {
"plugins": [
{
"transform": "@elliots/typical-tsc-plugin",
"transformProgram": true
}
]
}
}

Then run ts-patch's tsc:

npx ts-patch install
npx tsc

Or add a prepare script:

{
"scripts": {
"prepare": "ts-patch install -s",
"build": "tsc"
}
}

Configuration

Create a typical.json file in your project root (optional):

{
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules/**", "**/*.d.ts"],
"validateFunctions": true,
"validateCasts": false
}

Options

OptionDefaultDescription
include["**/*.ts", "**/*.tsx"]Files to transform
exclude["node_modules/**", "**/*.d.ts", "dist/**", "build/**"]Files to skip
validateFunctionstrueValidate function parameters and return types
validateCastsfalseValidate type assertions (as Type)
transformJSONParsetrueTransform JSON.parse to validate and filter to typed properties
transformJSONStringifytrueTransform JSON.stringify to only include typed properties

JSON Transformations

Typical automatically transforms JSON.parse and JSON.stringify calls when type information is available.

JSON.parse

When you cast the result of JSON.parse, Typical validates the parsed data and filters it to only include properties defined in your type:

interfaceUser{name: string;age: number;}// Input: '{"name":"Alice","age":30,"password":"secret"}'constuser=JSON.parse(jsonString)asUser;// Result: { name: "Alice", age: 30 } - password is filtered out!// Throws TypeError if name isn't a string or age isn't a number

JSON.stringify

When you use a type assertion with JSON.stringify, only properties defined in your type are included - preventing accidental data leaks:

interfacePublicUser{name: string;age: number;}constuser={name: "Alice",age: 30,password: "secret",ssn: "123-45-6789"};constjson=JSON.stringify(userasPublicUser);// Result: '{"name":"Alice","age":30}' - sensitive data excluded!

Both patterns detect type information from:

  • Type assertions: JSON.parse(str) as User or JSON.stringify(obj as User)
  • Variable declarations: const user: User = JSON.parse(str)
  • Function return types: function getUser(): User { return JSON.parse(str) }

How It Works

Typical uses a Go-based compiler that leverages the TypeScript type checker to analyze your code. It generates runtime validators that check values against their declared types.

Types that can't be validated at runtime (like generic type parameters T) are skipped. You can still use any and unknown to opt out of validation.

Compiler Optimisations

The generated validation code is optimised for runtime performance:

  • Reusable validators - When the same type is validated multiple times, Typical hoists the validation logic to a reusable function at module scope. Nested types that appear in multiple places (e.g., Address used in both User and Company) are also extracted and reused.
  • Smart redundancy elimination - Skips validation when returning values that are already known to be valid: validated parameters, properties of validated objects, variables assigned from casts or JSON.parse, and aliased variables
  • Cross-file call graph analysis - Analyses the entire project to eliminate redundant validation across files:
    • Trusted return values - If a function validates its return type, callers don't re-validate the result
    • Internal function parameters - Non-exported functions only called with pre-validated arguments skip parameter validation
    • Chained function calls - When step2(step1(user)) is called, validation flows through the chain
  • Type-aware dirty tracking - Tracks when validated values might become invalid. Primitives stay valid after being passed to functions (they're copied), but objects are re-validated if passed to unknown functions. Pure functions (listed in the config) like console.log don't invalidate objects.
  • Union early bail-out - Union type checks use if-else chains so the first matching type succeeds immediately
  • Skip comments - Add // @typical-ignore before a function to skip all validation for it

VSCode Extension

A VSCode extension is available that shows runtime validation indicators directly in your editor. It's not yet published to the marketplace, but you can build and install it locally.

Features

  • Subtle underlines on validated parameters, return values, type casts, and JSON operations
    • Green dotted underline = validated at runtime
    • Grey dotted underline = skipped (e.g., generic types)
  • Hover tooltips explaining what's being validated and why
  • Optional inlay hints showing validation status inline
  • Preview command to see the compiled output with validation code

Building and Installing

# Navigate to the extension directorycd packages/vscode-extension
# Install dependencies
pnpm install
# Build and package the extension
pnpm run build
pnpm run package
# Install the .vsix file
code --install-extension typical-vscode-0.0.1.vsix

Or use the convenience script:

cd packages/vscode-extension
pnpm run dev-install

Requirements

  • Your project must have @elliots/typical or @elliots/typical-compiler as a dependency
  • The extension uses the compiler binary from your project's node_modules

Debugging

Set DEBUG=1 for verbose logging:

DEBUG=1 npm run build

Limitations

Types that cannot be validated at runtime

These TypeScript features have no runtime representation and are skipped:

FeatureWhyExample
Generic type parametersNo runtime type info for Tfunction process<T>(x: T): T
Conditional typesCompile-time onlyT extends string ? A : B
infer keywordCompile-time type inferenceT extends Array<infer U> ? U : never
keyof operatorCompile-time key extractionkeyof User
Indexed access typesCompile-time type lookupUser['name']
Unique symbolsSymbol identity not checkabledeclare const id: unique symbol
Index signature valuesWould require iterating all keys{ [key: string]: number }

Other limitations

  • Type-only imports - import type { MyClass } can't use instanceof (class doesn't exist at runtime)
  • Function signatures - Only validates typeof === 'function', not parameter/return types
  • Function overloads - Validates the implementation signature, not individual overload signatures
  • Complex library types - DOM types, React types, etc. may exceed complexity limits (configurable via maxGeneratedFunctions)

What IS validated

Despite these limitations, Typical validates most practical TypeScript patterns:

  • All primitive types (string, number, boolean, bigint, symbol, null, undefined)
  • Object properties and nested objects
  • Arrays and tuples (including variadic tuples)
  • Union and intersection types
  • Literal types and template literal types
  • Enums (string and numeric)
  • Utility types (Partial, Required, Pick, Omit, Record, Extract, Exclude)
  • Mapped and conditional types (when resolved to concrete types)
  • Branded/opaque types (validates the underlying primitive)
  • Class instances (via instanceof)
  • Built-in types (Date, Map, Set, URL, Error, etc.)

Benchmarks

Runtime validation performance comparing Typical vs Zod vs no validation:

ScenarioNothingTypicalZodvs Nothingvs Zod
string23.91M/s24.86M/s24.80M/s🟡 1.0x🟡 1.0x
number24.33M/s25.44M/s24.44M/s🟡 1.0x🟡 1.0x
boolean24.49M/s24.49M/s24.19M/s🟡 1.0x🟡 1.0x
object w/ template literals24.53M/s21.39M/s7.71M/s🟡 0.9x🟢 2.8x
nested w/ template literals24.69M/s8.05M/s2.31M/s🔴 0.3x🟢 3.5x
array w/ templates (10)29.89M/s7.10M/s1.54M/s🔴 0.2x🟢 4.6x
array w/ templates (100)30.18M/s795.31K/s150.09K/s🔴 0.0x🟢 5.3x
union types29.77M/s30.69M/s10.76M/s🟡 1.0x🟢 2.9x
template literals30.09M/s17.23M/s1.71M/s🔴 0.6x🟢 10.1x
complex config30.56M/s29.14M/s3.51M/s🟡 1.0x🟢 8.3x
JSON.parse (small)4.61M/s4.37M/s3.85M/s🟡 0.9x🟢 1.1x
JSON.parse (small+filtered extras)4.65M/s4.32M/s3.79M/s🟡 0.9x🟢 1.1x
JSON.parse (medium)2.85M/s2.26M/s928.42K/s🔴 0.8x🟢 2.4x
JSON.parse (large)209.41K/s186.91K/s99.28K/s🟡 0.9x🟢 1.9x
JSON.parse (1000 large)211/s212/s104/s🟡 1.0x🟢 2.0x
JSON.stringify (small)9.99M/s9.30M/s6.70M/s🟡 0.9x🟢 1.4x
JSON.stringify (small+extras)2.85M/s9.20M/s6.98M/s🟢 3.2x🟢 1.3x
JSON.stringify (medium)5.09M/s3.82M/s1.16M/s🔴 0.8x🟢 3.3x
JSON.stringify (large)392.53K/s330.45K/s132.50K/s🔴 0.8x🟢 2.5x
JSON.stringify (1000 large)362/s339/s128/s🟡 0.9x🟢 2.7x
  • vs Nothing: Speed relative to no validation or filtering (1.0x = same speed)
  • vs Zod: Speed relative to Zod (1.0x = same speed)

Changelog

v0.3.1 (2026-02-23)

Add metro transformer (React Native/Expo)

v0.3.0 (2026-01-14)

  • Build call graph across whole project to avoid validating already-validated data
  • Source map vis in the playround
  • Add 'never' support, the property must not exist
  • Fix: Node 24

About

Typical is TypeScript with type-safety at runtime.

Topics

Resources

Stars

38 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages