TypeScript types as a runtime JSON validator, live in the browser
There is a small thing that has bugged me for years. I fetch some JSON from an API, I write a TypeScript type for the shape I expect back, and then at runtime the two are free to disagree. The type was erased during compilation, so nothing actually checks that the data matches what I declared.
The usual answer is a library like Zod, or JSON Schema. To be fair, Zod does not make me write the shape twice. I write the schema and let z.infer hand me the type.
const Root = z.object({ id: z.number(), name: z.string(), tags: z.array(z.string()) });
type Root = z.infer<typeof Root>;
What puts me off is the schema language itself. It is a second, more verbose way to say something TypeScript already says plainly.
type Root = { id: number; name: string; tags: string[] };
The plain type is just nicer to read and write, especially for the nested API shapes further down this page. TypeScript already knows how to check a value against a type like that, it just throws the type away before runtime. So I wanted to see how far I could get keeping the plain type and borrowing the compiler to do the checking. I built this little playground (well, Claude did most of the typing) to find out.
It is live. Pick a sample, or paste your own JSON into the value pane. The type on top starts as a guess inferred from the data, and you edit it to tighten the shape. The value below stays plain JSON, and it is rechecked against the type as you type. It feels like JSON Schema, except the schema is a TypeScript type and the thing doing the checking is the real compiler.
type Root — edit it to tighten the shapeRoot as you type.Before the code, here is the whole idea on one page. A JSON value comes in, a type Root is guessed from it, and that single type then feeds two separate things. One path drives the live squiggles you just saw. The other turns the type into a real function you can call.
The rest of the post walks each arrow. First the checker, then the inference on the left edge, then the validator on the right.
Every code block below is pulled out of the demo’s actual source at build time, not retyped into the post, so what you read here is the code the demo runs. The validator pipeline is also run once at build by a small harness, and its real output is shown near the end.
The compiler is the validator
There is no validator here, and the value pane is not really checked as JSON at all. The demo keeps three Monaco models. The type Root you edit, the JSON value you edit, and a hidden TypeScript file you never see.
// The `type Root` the reader edits.
const typeModel = monaco.editor.createModel(
seedType,
'typescript',
monaco.Uri.parse('file:///root.ts'),
);
// The plain JSON value the reader edits.
const jsonModel = monaco.editor.createModel(
seed,
'json',
monaco.Uri.parse('file:///value.json'),
);
// No editor is attached to this one. The worker still checks it because
// it is a registered TypeScript model.
const dataModel = monaco.editor.createModel(
setup.buildDataSource(seed),
'typescript',
monaco.Uri.parse('file:///data.ts'),
); The two panes you edit never touch each other directly. They both feed the hidden file, the worker checks that file, and its complaints are copied back onto the JSON pane.
The hidden file is the whole trick. It is just your JSON dropped into a typed assignment, rebuilt on every keystroke in the value pane.
const DATA_HEADER = 'const data: Root =';
export function buildDataSource(jsonText: string): string {
return `${DATA_HEADER}\n${jsonText.replace(/\s+$/, '')}\n;\n`;
} JSON is valid TypeScript syntax. So it sits there as a fresh object literal assigned to a typed target, and the compiler checks it structurally. A missing field, a wrong primitive, or a null where the type does not allow one all become errors. Because it is a fresh literal, TypeScript also flags properties that are not in the type at all. That is the strict mode of a schema library, for free.
Monaco runs the real TypeScript language service in a web worker, the same one VS Code uses. That worker will hand me the errors for any file it knows about. So whenever a pane changes, I ask it about the hidden file and copy the results onto the JSON pane.
const getWorker = await monaco.typescript.getTypeScriptWorker();
const client = await getWorker(dataModel.uri);
const uri = dataModel.uri.toString();
const diagnostics = [
...(await client.getSyntacticDiagnostics(uri)),
...(await client.getSemanticDiagnostics(uri)),
];
const flatten = (m: string | { messageText: string; next?: unknown[] }): string =>
typeof m === 'string' ? m : m.messageText;
const lineCount = jsonModel.getLineCount();
let errors = 0;
const markers = diagnostics
.filter((d) => typeof d.start === 'number')
.map((d) => {
const start = dataModel.getPositionAt(d.start as number);
const end = dataModel.getPositionAt((d.start as number) + (d.length ?? 0));
const isError = d.category === 1;
if (isError) errors += 1;
return {
// TS line 1 is the header, so shift JSON up by one line and clamp.
startLineNumber: Math.min(Math.max(start.lineNumber - 1, 1), lineCount),
startColumn: start.column,
endLineNumber: Math.min(Math.max(end.lineNumber - 1, 1), lineCount),
endColumn: end.column,
message: flatten(d.messageText as never),
severity: isError
? monaco.MarkerSeverity.Error
: d.category === 0
? monaco.MarkerSeverity.Warning
: monaco.MarkerSeverity.Info,
};
});
monaco.editor.setModelMarkers(jsonModel, 'typeprobe', markers); That one header line is the only bookkeeping. Each diagnostic comes back pointing at the hidden file, so I shift it up one line to land on the matching JSON row and clamp it to the pane. Monaco’s own JSON validation is switched off, so the only squiggles on the value pane are the ones TypeScript put there.
(Recent Monaco exposes this worker as monaco.typescript. Older versions keep it under monaco.languages.typescript.)
How to infer the Root type
When you load JSON, I walk the parsed value and emit a first guess at type Root. There is no compiler in this step, just a recursive walk over the parsed value. Each value describes itself. A string becomes string, an array describes its elements, an object describes its keys.
// Describe a single observed value as a type string.
function describe(value: unknown, depth: number): string {
if (value === null) return 'null';
if (Array.isArray(value)) return arrayType(value, depth);
if (isPlainObject(value)) return mergeObjects([value], depth);
switch (typeof value) {
case 'string':
return 'string';
case 'number':
return 'number';
case 'boolean':
return 'boolean';
default:
return 'unknown';
}
} Arrays of objects are the fun case, and they are why a single describe is not enough. Two elements of the same array can disagree, so I pool every observation and unify it into one type. Objects merge structurally, arrays have their elements pooled, and scalars contribute their primitive to a union.
// Unify a set of observed values into one type. Object values are merged
// structurally, array values have their elements pooled and unified, and
// scalars contribute their primitive type to a union.
function unify(values: unknown[], depth: number): string {
const objects: Record<string, unknown>[] = [];
const arrays: unknown[][] = [];
const scalars: string[] = [];
for (const value of values) {
if (Array.isArray(value)) arrays.push(value);
else if (isPlainObject(value)) objects.push(value);
else scalars.push(describe(value, depth));
}
const parts: string[] = [];
if (arrays.length > 0) {
const pooled = arrays.flat();
if (pooled.length === 0) {
parts.push('unknown[]');
} else {
const element = unify(pooled, depth);
parts.push(element.includes('|') ? `(${element})[]` : `${element}[]`);
}
}
if (objects.length > 0) {
parts.push(mergeObjects(objects, depth));
}
parts.push(...scalars);
return unionOf(parts);
} function arrayType(items: unknown[], depth: number): string {
if (items.length === 0) return 'unknown[]';
const element = unify(items, depth);
return element.includes('|') ? `(${element})[]` : `${element}[]`;
} Merging objects is where the optional keys come from. A key that shows up in some objects but not others becomes optional, and a key’s type is the unification of every value seen for it.
// Merge one or more object observations into a single object type.
// A key absent from some observations is optional. The value type of a
// key is the unification of every value seen for it.
function mergeObjects(objects: Record<string, unknown>[], depth: number): string {
const keyOrder: string[] = [];
const seen = new Set<string>();
for (const obj of objects) {
for (const key of Object.keys(obj)) {
if (!seen.has(key)) {
seen.add(key);
keyOrder.push(key);
}
}
}
if (keyOrder.length === 0) return '{}';
const pad = INDENT.repeat(depth + 1);
const closePad = INDENT.repeat(depth);
const lines = keyOrder.map((key) => {
const present = objects.filter((obj) => Object.prototype.hasOwnProperty.call(obj, key));
const optional = present.length < objects.length;
const valueType = unify(present.map((obj) => obj[key]), depth + 1);
return `${pad}${quoteKey(key)}${optional ? '?' : ''}: ${valueType};`;
});
return `{\n${lines.join('\n')}\n${closePad}}`;
} That is the whole inferrer, indentation and all. The text it returns is exactly what lands in the top pane when you load a sample.
It is only a guess from one sample though. If a field happened to be null in this particular response, the guess is null and nothing more. You tighten it to string | null by hand, and the squiggles tell you right away whether the rest of the data still fits.
Generating a runtime validator
Squiggles are lovely while editing, but sometimes I want an actual function I can call. That is the “Generate validator” button, and it is the part I find most interesting, so here is the whole thing. The reader does not have to take the output on faith either. Once a validator is generated, “Run it against the value above” runs it in your browser and tells you what it returned.
The idea is to ask the TypeScript Compiler API for the resolved shape of Root and walk it. There are four small steps. Stand up a compiler, resolve the type, walk it into a string of checks, then strip the types so the string runs.
Spinning up an in-browser compiler
The compiler normally reads from disk. In the browser there is no disk, so I use @typescript/vfs to give it an in-memory filesystem with two files in it. The first is a tiny standard library, because the checker insists a few globals exist.
// A tiny standard library. The inferred types are self-contained (objects,
// arrays, primitives, unions), so the checker only needs the global types it
// insists exist under noLib, plus Array so that `T[]` has meaning. This keeps
// us off the network and out of multi-megabyte real lib files.
const MINIMAL_LIB = `
interface Array<T> { length: number; [index: number]: T; }
interface ReadonlyArray<T> { readonly length: number; readonly [index: number]: T; }
interface Boolean {}
interface Number {}
interface String { readonly length: number; }
interface Object {}
interface Function {}
interface CallableFunction extends Function {}
interface NewableFunction extends Function {}
interface IArguments {}
interface RegExp {}
`; The second is the edited type text, and the two together are enough to stand up a program and pull out the checker.
// Stand up an in-memory TypeScript program over a tiny lib plus the edited type
// text, and hand back the program and the parsed source file. There is no disk
// in the browser, so @typescript/vfs gives the compiler a Map of files instead.
function makeChecker(ts: TS, vfs: typeof import('@typescript/vfs'), typeText: string) {
const fsMap = new Map<string, string>();
fsMap.set('/lib.d.ts', MINIMAL_LIB);
fsMap.set('/index.ts', `${typeText}\n`);
const system = vfs.createSystem(fsMap);
const env = vfs.createVirtualTypeScriptEnvironment(
system,
['/lib.d.ts', '/index.ts'],
ts,
{ target: ts.ScriptTarget.ES2020, strict: true, noLib: true },
);
const program = env.languageService.getProgram();
if (!program) throw new Error('Could not start the TypeScript program.');
const source = program.getSourceFile('/index.ts');
if (!source) throw new Error('Could not read the type source.');
return { program, source };
} The noLib option turns off the real standard library, and the minimal stub takes its place. The checker only needs to know that Array and a handful of globals exist for string[] to mean something.
From a name to a resolved type
Now I find the Root declaration in the source and ask the checker what it actually is.
// Find the `Root` declaration and ask the checker what it resolved to. This is
// the step that pays for the whole compiler: `string[]` already means an array
// of strings and `A | B` is already a flattened union, with no syntax to parse.
function findRoot(ts: TS, checker: TypeChecker, source: SourceFile): Type | undefined {
for (const statement of source.statements) {
if (
(ts.isTypeAliasDeclaration(statement) || ts.isInterfaceDeclaration(statement)) &&
statement.name.text === 'Root'
) {
const symbol = checker.getSymbolAtLocation(statement.name);
if (symbol) return checker.getDeclaredTypeOfSymbol(symbol);
}
}
return undefined;
} This is the step that pays for the whole compiler. getDeclaredTypeOfSymbol hands back the resolved type, with string[] already understood as an array of strings and A | B already flattened into a union. I never have to parse type syntax myself. I just ask what it resolved to.
Walking the type into checks
With a resolved type in hand, I walk it and build a string of boolean checks. Each part of the type maps to a piece of the check, and the walk just follows the type’s shape.
type Root = { id: number; tags: string[]; }
typeof v === "object" && v !== null && typeof v.id === "number" && Array.isArray(v.tags) && v.tags.every(e => typeof e === "string")
typeof
test, an object an && of its fields, an array an Array.isArray
plus a .every that runs the element's check. The walk just follows the type's shape.
A Type carries a bitmask of flags for the primitives, and the checker has helpers for the rest. This is the whole walker, every case it handles, straight from the source.
// Build the boolean expression that is true exactly when `expr` matches `type`.
// Returns 'true' for anything we cannot or need not check (any, unknown), so
// callers can drop those terms from an && chain. `indent` is the current
// nesting level (in 2-space units) for formatting object groups, and `depth`
// names the `.every` element variable so nested arrays do not shadow.
function checkType(
ts: TS,
checker: TypeChecker,
type: Type,
expr: string,
indent = 1,
depth = 0,
): string {
const flags = type.flags;
const F = ts.TypeFlags;
if (flags & (F.Any | F.Unknown)) return 'true';
if (flags & F.String) return `typeof ${expr} === "string"`;
if (flags & F.Number) return `typeof ${expr} === "number"`;
if (flags & (F.Boolean | F.BooleanLiteral)) {
// A boolean literal (`true`/`false`) narrows to an exact value.
if (flags & F.BooleanLiteral) {
const name = (type as { intrinsicName?: string }).intrinsicName;
if (name === 'true' || name === 'false') return `${expr} === ${name}`;
}
return `typeof ${expr} === "boolean"`;
}
if (flags & F.Null) return `${expr} === null`;
if (flags & F.Undefined) return `${expr} === undefined`;
if (flags & F.StringLiteral) {
return `${expr} === ${JSON.stringify((type as unknown as { value: string }).value)}`;
}
if (flags & F.NumberLiteral) {
return `${expr} === ${(type as unknown as { value: number }).value}`;
}
if (type.isUnion()) {
const terms = type.types
// JSON never carries `undefined`; optional members are handled by the
// missing-property branch in the object walker.
.filter((member) => (member.flags & F.Undefined) === 0)
.map((member) => checkType(ts, checker, member, expr, indent, depth))
.filter((term) => term !== 'true');
if (terms.length === 0) return 'true';
if (terms.length === 1) return terms[0];
return `(${terms.join(' || ')})`;
}
if (checker.isArrayType(type)) {
const element = checker.getTypeArguments(type as never)[0];
const itemVar = depth === 0 ? 'item' : `item${depth + 1}`;
const elementCheck = element
? checkType(ts, checker, element, itemVar, indent, depth + 1)
: 'true';
if (elementCheck === 'true') return `Array.isArray(${expr})`;
return `Array.isArray(${expr}) && ${expr}.every((${itemVar}: any) => ${elementCheck})`;
}
if (flags & F.Object) {
const props = checker.getPropertiesOfType(type);
const terms: string[] = [
`typeof ${expr} === "object"`,
`${expr} !== null`,
`!Array.isArray(${expr})`,
];
for (const prop of props) {
const decl = prop.valueDeclaration;
const propType = checker.getTypeOfSymbolAtLocation(prop, decl ?? (type.symbol?.valueDeclaration as never));
const member = access(expr, prop.name);
const optional = (prop.flags & ts.SymbolFlags.Optional) !== 0;
let propCheck = checkType(ts, checker, propType, member, indent + 1, depth);
// An optional member may be missing entirely; otherwise it must match.
if (optional) {
propCheck = propCheck === 'true' ? 'true' : `(${member} === undefined || ${propCheck})`;
}
if (propCheck !== 'true') terms.push(propCheck);
}
const pad = ' '.repeat(indent + 1);
const closePad = ' '.repeat(indent);
return `(\n${pad}${terms.join(` &&\n${pad}`)}\n${closePad})`;
}
// Anything else (functions, symbols, tuples) is not expected from JSON.
return 'true';
} Every branch is the checker doing the work. isUnion gives me the members, isArrayType plus getTypeArguments gives me the element type, and getPropertiesOfType plus getTypeOfSymbolAtLocation give me each field and its type. Optional fields show up as a flag on the property symbol, so they are allowed to be missing, and undefined is dropped from unions because JSON never carries it. The indent and depth arguments only shape the output. indent keeps nested object groups readable. depth names each array’s element variable so a nested .every does not shadow the outer one.
The walker hands back one big boolean expression, and wrapping it in a type guard is the whole validator.
// Walk Root into one big boolean expression, then wrap it in a type guard.
const body = checkType(ts, checker, rootType, 'v');
const code = [
'function validate(value: unknown): value is Root {',
' const v = value as any;',
` return ${body};`,
'}',
].join('\n'); The ${body} is that expression spliced straight into the source, so return (...) is the entire walked check.
Stripping the types so it runs
That output is still TypeScript. It has : unknown and a value is Root predicate, which the browser cannot run. Rather than strip the annotations with a fragile regex, I hand the string back to the compiler and let it erase the types for me.
const js = ts.transpile(code, { target: ts.ScriptTarget.ES2020 });
validate = new Function(`${js}\nreturn validate;`)() as (value: unknown) => boolean; That is exactly what the “Run it against the value above” button does.
Put together it is the whole generator. Stand up a checker, resolve Root, walk it into a string, and transpile the string so it runs. This is the part a schema library cannot do from a type alone. It works straight from the type you already wrote, instead of asking you to copy it into a schema.
To prove the snippets above are not just for show, here is a small script that imports the very same inferType and generateValidator the demo uses, infers a type from a sample value, generates the validator, and runs it. The output is captured when the site is built, so if any of this stopped working the build would fail instead of shipping a wrong post.
// A sample value, as if it came back from an API.
const sample = {
login: 'octocat',
id: 583231,
name: 'The Octocat',
public_repos: 8,
followers: 1500,
};
// 1. Infer a candidate type from the value.
const typeText = inferType(sample);
console.log(typeText);
// 2. Generate a runtime validator by walking that type with the compiler.
const { code, error, validate } = await generateValidator(typeText);
if (error || !validate) throw new Error(error ?? 'no validator produced');
console.log('\n' + code);
// 3. Run it. The original value passes; a wrong-shaped one does not.
const broken = { login: 'octocat', id: '583231' };
console.log('\nvalidate(sample) =>', validate(sample));
console.log('validate(broken) =>', validate(broken)); type Root = {
login: string;
id: number;
name: string;
public_repos: number;
followers: number;
};
function validate(value: unknown): value is Root {
const v = value as any;
return (
typeof v === "object" &&
v !== null &&
!Array.isArray(v) &&
typeof v.login === "string" &&
typeof v.id === "number" &&
typeof v.name === "string" &&
typeof v.public_repos === "number" &&
typeof v.followers === "number"
);
}
validate(sample) => true
validate(broken) => false
Where it breaks
This is a toy, not a library. The type guesser handles the JSON shapes I actually run into, and not tuples, recursive types, generics, or index signatures. The generated validator covers objects, arrays, unions, primitives, optionals, and null, and stops there.
Replacing Zod was never the point. I just wanted to see that a TypeScript type already carries enough to validate real data, and that the compiler will hand that back to you if you ask nicely.