Runtime Validation & Type-Safe APIs
TypeScript's types exist only while you compile. When your program runs, data arrives from places the compiler never saw: an API response, a form, a JSON file, an environment variable. If you write const user = (await res.json()) as User, TypeScript simply believes you. When the server sends "age": "34" as a string, your "fully typed" code breaks in production.
The fix is runtime validation: checking data when it enters your program, at the boundary, and turning it into trusted, typed values. In this lesson you will build a tiny schema toolkit to see how the idea works, then use Zod, the most widely used schema library, and finish with branded types and an overview of type-safe APIs.
Validate at the boundary
Think of your program as a house. Inside, every value has a known type and TypeScript keeps it honest. At the door, anything can arrive. Check it once at the door, and the code inside never has to worry.
The doors in a typical app are: HTTP request bodies and query strings, responses from other services, localStorage, files, message queues and environment variables. Everything that crosses a door starts as unknown (see any, unknown & never).
You already know one way to check: a hand-written type guard (see Type Guards & Assertions). It works, but for a real API with twenty fields you end up writing the type and a long guard, and they slowly drift apart. Schema libraries solve this by letting you write the rules once and derive the TypeScript type from them.
How schemas infer types
A schema is a value that can check data at run time and also carries a type at compile time. This toy version, about 30 lines, shows the trick every schema library uses:
type Schema<T> = { parse: (input: unknown, path?: string) => T };
type Infer<S> = S extends Schema<infer T> ? T : never;
const str = (): Schema<string> => ({
parse(input, path = "value") {
if (typeof input !== "string") throw new Error(`${path}: expected string`);
return input;
},
});
const num = (): Schema<number> => ({
parse(input, path = "value") {
if (typeof input !== "number" || Number.isNaN(input)) throw new Error(`${path}: expected number`);
return input;
},
});
function obj<Shape extends Record<string, Schema<unknown>>>(
shape: Shape,
): Schema<{ [K in keyof Shape]: Infer<Shape[K]> }> {
return {
parse(input, path = "value") {
if (typeof input !== "object" || input === null) throw new Error(`${path}: expected object`);
const out: Record<string, unknown> = {};
for (const key in shape) {
out[key] = shape[key].parse((input as Record<string, unknown>)[key], `${path}.${key}`);
}
return out as { [K in keyof Shape]: Infer<Shape[K]> };
},
};
}
const Student = obj({ name: str(), city: str(), marks: num() });
type Student = Infer<typeof Student>; // { name: string; city: string; marks: number }
const fromApi: unknown[] = [
{ name: "Ananya", city: "Kochi", marks: 91 },
{ name: "Kabir", city: "Pune", marks: "88" },
];
for (const raw of fromApi) {
try {
const s: Student = Student.parse(raw);
console.log(`${s.name} from ${s.city}: ${s.marks.toFixed(1)}`);
} catch (err) {
console.log(`Rejected: ${err instanceof Error ? err.message : err}`);
}
}Ananya from Kochi: 91.0 Rejected: value.marks: expected number
Two ideas carry this whole example. First, every schema has the type Schema<T>, so the conditional type Infer (see Conditional Types & infer) can pull T back out. Second, obj uses a mapped type to build the object type from the shape. You wrote the rules once; the type Student came for free and can never disagree with the check.
Real libraries add hundreds of conveniences: optional fields, arrays, unions, string formats, custom messages, error paths, transformations. That is why you should use one instead of growing your own.
Using Zod
Zod (version 4 at the time of writing) is installed with npm install zod. The same Booking rules look like this:
import { z } from "zod";
const Booking = z.object({
pnr: z.string().regex(/^\d{10}$/),
passenger: z.string().min(2),
age: z.number().int().min(1).max(120),
berth: z.enum(["lower", "middle", "upper"]).optional(),
});
type Booking = z.infer<typeof Booking>;
const result = Booking.safeParse({ pnr: "45218", passenger: "M", age: "34" });
if (!result.success) {
console.log(z.prettifyError(result.error));
} else {
console.log(result.data.passenger); // result.data is a Booking
}✖ Invalid string: must match pattern /^\d{10}$/
→ at pnr
✖ Too small: expected string to have >=2 characters
→ at passenger
✖ Invalid input: expected number, received string
→ at ageA few things to notice:
z.infer<typeof Booking>gives the type{ pnr: string; passenger: string; age: number; berth?: "lower" | "middle" | "upper" | undefined }.safeParsenever throws. It returns a discriminated union:{ success: true, data }or{ success: false, error }, the same Result idea from Error Handling Patterns.parsereturns the data or throws aZodError. Use it where a failure really is exceptional, such as reading config at startup.error.issuesis an array with apathandmessagefor each problem, which you can map to form fields.
Validating environment variables at startup is a classic use. If DATABASE_URL is missing, the app should stop immediately with a clear message, not fail an hour later:
import { z } from "zod";
const Env = z.object({
NODE_ENV: z.enum(["development", "production", "test"]),
PORT: z.coerce.number().int().default(3000),
DATABASE_URL: z.url(),
});
export const env = Env.parse(process.env); // throws once, at startup, if anything is wrongz.coerce.number() converts the string "8080" from the environment into the number 8080 before checking it.
Other schema libraries and Standard Schema
Zod is not the only choice. You will meet these in Indian product companies and open-source projects:
| Library | Style | Known for |
|---|---|---|
| Zod | Method chains: z.string().min(2) | Most popular, large ecosystem |
| Valibot | Small functions: pipe(string(), minLength(2)) | Very small browser bundles |
| ArkType | Type-like strings: "string >= 2" | Syntax close to TypeScript itself |
These libraries (and others) implement Standard Schema, a shared interface: every schema exposes a ~standard property with a validate function. Frameworks and form libraries that accept "any Standard Schema" therefore work with whichever library your team picked. The specification is at standardschema.dev.
Branded types: parse, don't just check
Sometimes a value is a string but not any string: a validated PIN code, an email you have checked, an order ID. A branded type adds an invisible tag so plain strings cannot be passed by accident:
type Pin = string & { readonly __brand: "Pin" };
function parsePin(input: string): Pin | null {
return /^\d{6}$/.test(input) ? (input as Pin) : null;
}
function deliveryCharge(pin: Pin): number {
return pin.startsWith("1") ? 40 : 60;
}
const pin = parsePin("124001");
if (pin) {
console.log(`Charge for ${pin}: ₹${deliveryCharge(pin)}`);
}
console.log(parsePin("12A001"));
// deliveryCharge("124001"); // error: a plain string is not a PinCharge for 124001: ₹40 null
The only way to get a Pin is through parsePin, so any function that asks for a Pin knows it was validated. This is the "parse, don't validate" idea: instead of checking data and keeping the loose type, convert it into a stricter type once. The brand exists only in the type system; at run time pin is a normal string. Zod supports the same thing with .brand<"Pin">().
Type-safe APIs end to end
Validation protects one side of a network call. In larger systems, teams also want the client to know the server's types without copying them by hand. Common approaches:
- Shared schemas in a monorepo: the server validates requests with a Zod schema and the frontend imports the inferred types (see Project References & Monorepos).
- OpenAPI: the API is described in an OpenAPI document, and a generator such as
openapi-typescriptproduces TypeScript types for every route. - tRPC: for TypeScript on both sides, the client calls server functions directly and gets their types without any code generation.
- GraphQL code generation: types are generated from the GraphQL schema and your queries.
Whatever you choose, the rule stays: data from outside is unknown until a schema has checked it.
Common mistakes
- Using
ason external data.res.json() as Orderchecks nothing. Parse it. - Writing the type and the schema separately. Derive the type with
z.inferso they cannot drift. - Validating deep inside the app. Validate once at the boundary and pass typed values inward.
- Showing raw validation errors to users. Map
issuesto friendly, field-level messages. - Forgetting that environment variables are strings. Use coercion (
z.coerce.number()) for ports and limits.
Key points
- TypeScript types disappear at run time, so data from outside must be validated when it enters.
- A schema checks data at run time and carries a type at compile time;
z.inferextracts it. safeParsereturns a success/failure union;parsethrows aZodError.- Zod, Valibot and ArkType implement Standard Schema, so many tools accept any of them.
- Branded types mark values that passed validation, so unchecked values cannot be passed by mistake.
Exercise
Using the toy str, num and obj helpers from this lesson, create a Product schema with sku (string), name (string) and price (number). Parse this array from a "supplier feed" and print the valid products and the number rejected: [{ sku: "P-1", name: "Steel bottle", price: 349 }, { sku: "P-2", name: "Tiffin box" }, { sku: "P-3", name: "Umbrella", price: 499 }].
Show solution
// str, num and obj are the helpers from "How schemas infer types"
const Product = obj({ sku: str(), name: str(), price: num() });
type Product = Infer<typeof Product>;
const feed: unknown[] = [
{ sku: "P-1", name: "Steel bottle", price: 349 },
{ sku: "P-2", name: "Tiffin box" },
{ sku: "P-3", name: "Umbrella", price: 499 },
];
const valid: Product[] = [];
let rejected = 0;
for (const raw of feed) {
try {
valid.push(Product.parse(raw));
} catch {
rejected++;
}
}
valid.forEach((p) => console.log(`${p.sku} ${p.name} ₹${p.price}`));
console.log(`${rejected} rejected`);This prints P-1 Steel bottle ₹349, P-3 Umbrella ₹499 and 1 rejected. Product is inferred from the schema, so the array of valid items is fully typed.
Quick Quiz
1. Why is const order = (await res.json()) as Order risky?
Type assertions change only what the compiler believes, not the data.
2. What does z.infer<typeof Booking> give you?
Schema libraries derive the static type from the runtime schema.
3. What does Booking.safeParse(data) return when the data is invalid?
safeParse reports failure in its return value; parse throws.
4. What is the main benefit of a branded type such as Pin?
The brand is a compile-time tag that plain strings do not have.
Learn TypeScript with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
