Infer a Type from a Zod Schema
Maintaining a Zod runtime schema and a TypeScript interface that match by hand is a maintenance trap: every field change has to be edited twice. `z.infer<typeof Schema>` derives the static type from the schema, so the schema is the single source of truth. This snippet covers the basic infer pattern, the input/output split for transforms, and a typed parsing wrapper that turns runtime errors into a clean `Result` shape.
979 views
17
// Minimal runtime stub of zod so the snippet runs offline.
// In real code: import { z } from 'zod';
function zString() { return { parse(v: any) { if (typeof v !== 'string') throw new Error('not string'); return v; } }; }
function zNumber() { return { parse(v: any) { if (typeof v !== 'number') throw new Error('not number'); return v; } }; }
function zObject(shape: any) {
return {
parse(value: any) {
const out = {} as Record<string, unknown>;
for (const k of Object.keys(shape)) out[k] = shape[k].parse(value[k]);
return out;
},
};
}
// type User = z.infer<typeof UserSchema>; // in real code
const UserSchema = zObject({ 'id': zString(), 'age': zNumber() });
const rawUser = { 'id': 'u_1', 'age': 30 };
const parsedUser = UserSchema.parse(rawUser);
console.log(`${parsedUser.id} ${parsedUser.age}`);The headline is type User = z.infer<typeof UserSchema>: the schema becomes the source of truth, and the static type follows automatically. Real Zod's z.infer is a conditional type that walks the schema's runtime shape and reconstructs the equivalent TypeScript type, so adding email: z.string() to the schema instantly makes User.email available downstream without a separate type edit. The runtime stubs here keep the validator running offline; in your project, remove the stubs and import from zod directly. This pattern is the foundation of every typed boundary in modern TS apps (form validation, API request bodies, env var parsing).
interface SchemaShape<I, O> {
parse(value: I): O;
}
type InputOf<S> = S extends SchemaShape<infer I, unknown> ? I : never;
type OutputOf<S> = S extends SchemaShape<unknown, infer O> ? O : never;
const upper = {
parse(s: string): string {
return s.toUpperCase();
},
} as SchemaShape<string, string>;
type InType = InputOf<typeof upper>;
type OutType = OutputOf<typeof upper>;
const rawInput = 'hello' as InType;
const transformed = upper.parse(rawInput) as OutType;
console.log(`${rawInput} -> ${transformed}`);Schemas with transforms have a different input type than output type: z.string().transform((s) => Number(s)) accepts string but produces number. Real Zod ships z.input<typeof S> and z.output<typeof S> (and z.infer aliases z.output) so callers can pick the right shape per use case. The handwritten InputOf / OutputOf here are the same conditional infer pattern, simplified for the snippet. Use the input type at the boundary (request body parsing) and the output type after parsing succeeds (downstream business logic).
interface Schema2<O> {
parse(value: unknown): O;
}
type Result<T> = { ok: true; value: T } | { ok: false; error: string };
function safeParse<O>(schema: Schema2<O>, value: unknown): Result<O> {
try {
return { 'ok': true, 'value': schema.parse(value) };
} catch (err) {
const msg = err instanceof Error ? err.message : 'unknown error';
return { 'ok': false, 'error': msg };
}
}
const NumSchema = {
parse(v: unknown): number {
if (typeof v !== 'number') throw new Error('not a number');
return v;
},
} as Schema2<number>;
const good = safeParse(NumSchema, 42);
const bad = safeParse(NumSchema, 'no');
if (good.ok) console.log(`good ${good.value}`);
if (!bad.ok) console.log(`bad ${bad.error}`);Wrapping schema.parse in a Result<T> gives callers a discriminated union to switch on instead of a thrown exception, which is friendlier for HTTP handlers, form validation, and any boundary that wants to map errors to status codes. Real Zod ships schema.safeParse(value) with the same shape ({ success: true, data } or { success: false, error }), so callers can move the same Result pattern in and out without rewrites. The generic <O> flows from the schema through the wrapper so the success branch is fully typed without any cast at the call site.
