Runtime Validation in TypeScript
TypeScript types disappear at runtime. Learn how to validate data as it enters your application using type guards, assertion functions, and validation libraries to bridge the gap between compile-time types and runtime reality.
Runtime validation is the practice of checking that data matches your expected types while the program is running. TypeScript's type system operates only at compile time. When your code compiles to JavaScript, every type annotation, interface, and generic is stripped away. What remains has no knowledge of the types you wrote.
This matters whenever data comes from outside your code: API responses, user form input, query parameters, localStorage entries, file reads, or WebSocket messages. None of these arrive with type information. You must verify their shape at runtime before trusting them.
The diagram shows the principle: validate once at the boundary, then the rest of your application can trust the types. Every external entry point should have a validation layer.
Type Guards: Checking and Narrowing
A type guard is a function that returns a boolean and uses the value is Type return syntax to tell TypeScript what the check means.
function isString(value: unknown): value is string {
return typeof value === "string";
}
function processInput(input: unknown) {
if (isString(input)) {
console.log(input.toUpperCase());
}
}After the call to isString returns true, TypeScript narrows the input from unknown to string inside the if block. The return type is the instruction that enables this narrowing.
For object shapes, combine multiple runtime checks into one guard. Start with the shape you expect the data to match.
type User = {
id: number;
name: string;
email: string;
};The guard function checks every property individually before it will vouch for the whole shape, since a single missing or mistyped field should make the whole check fail.
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
typeof (value as Record<string, unknown>).id === "number" &&
"name" in value &&
typeof (value as Record<string, unknown>).name === "string" &&
"email" in value &&
typeof (value as Record<string, unknown>).email === "string"
);
}This is verbose but explicit. The guard returns true only when all checks pass, giving TypeScript full confidence that the value matches the User shape.
Assertion Functions: Validating or Throwing
An assertion function uses the asserts value is Type return syntax. Instead of returning a boolean, it throws if the check fails and narrows the type for all code after the call.
function assertUser(value: unknown): asserts value is User {
if (!isUser(value)) {
throw new Error("Expected a valid User object");
}
}Use assertions when invalid data should stop execution immediately, so the rest of the function can assume the shape is already correct.
function handleUserData(raw: unknown) {
assertUser(raw);
console.log(raw.name.toUpperCase());
}After the assertion returns without throwing, the value is typed as User for the rest of the function. No if block is needed because execution stops on failure.
| Pattern | Returns on success | Returns on failure | Type narrowed |
|---|---|---|---|
| Type guard | true | false | Inside if block |
| Assertion | void | throws | After call |
Choose a type guard when you want to branch. Choose an assertion when invalid data means the current operation cannot continue.
Validating at the Boundary
The most impactful place to add runtime validation is at the edges of your application. Every function that receives data from outside should validate before passing it inward.
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const json: unknown = await response.json();
assertUser(json);
return json;
}The response.json() call returns unknown because the network could deliver anything. The assertion turns that unknown value into a trusted User. Code that calls this function can rely on the return type without repeating the validation.
The same principle applies to form submissions, WebSocket messages, and environment variables. Validate at the entry point once.
Validation Beyond typeof and instanceof
Not every check is a simple typeof. Some validations require comparing against known values, checking ranges, or enforcing format constraints.
type Role = "admin" | "editor" | "viewer";
const VALID_ROLES: Role[] = ["admin", "editor", "viewer"];
function isRole(value: unknown): value is Role {
return typeof value === "string" && VALID_ROLES.includes(value as Role);
}For union types, check that the value is one of the allowed members. The includes call verifies membership at runtime.
Numbers often need range checks in addition to a basic type check.
function isPositiveInteger(value: unknown): value is number {
return typeof value === "number" && Number.isInteger(value) && value > 0;
}The typeof check confirms it is a number at runtime. The integer check and the range check add domain-specific constraints that the type system alone cannot express.
Writing Guards for Discriminated Unions
Discriminated unions are common for modeling API response states. This one models three possible states, distinguished by a shared status field.
type ApiState<T> =
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; message: string };Validate the discriminant first, then validate only the properties that matter for that particular variant, since a loading state and an error state need completely different follow-up checks.
function isApiState<T>(
value: unknown,
validateData: (val: unknown) => val is T
): value is ApiState<T> {
if (typeof value !== "object" || value === null) return false;
const obj = value as Record<string, unknown>;
switch (obj.status) {
case "loading": return true;
case "success": return "data" in obj && validateData(obj.data);
case "error": return "message" in obj && typeof obj.message === "string";
default: return false;
}
}The guard checks the discriminant first, then validates only the properties relevant to that variant. A loading state needs no further checks, while a success state must also have valid data.
For more on the underlying type pattern, see discriminated unions in TypeScript.
When to Reach for a Validation Library
Hand-written guards work well for a few simple types but become tedious for large, nested schemas. Validation libraries let you define the schema once and derive both the TypeScript type and the runtime validator.
Here is the idea with Zod, one popular library:
import { z } from "zod";
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.email(),
});
type User = z.infer<typeof UserSchema>;The schema object is both the runtime validator, called as UserSchema.parse(data), and the source of the User type. You write the schema once and get both compile-time and runtime safety.
For more on using Zod specifically, see use Zod with TypeScript. For validating the raw data before it even becomes an object, see parse JSON safely in TypeScript.
Rune AI
Key Insights
- TypeScript types are erased during compilation and do not exist at runtime.
- Use type guards (value is Type) to check and narrow unknown data inside if blocks.
- Use assertion functions (asserts value is Type) to throw on invalid data and narrow afterward.
- Validate data at the boundary where it enters your application, not deep inside.
- For complex schemas, libraries like Zod generate both types and validators from one source.
Frequently Asked Questions
Why do I need runtime validation if TypeScript checks types at compile time?
What is the difference between a type guard and an assertion function?
Should I write validation functions by hand or use a library?
Conclusion
TypeScript types are a compile-time safety net that disappears at runtime. Runtime validation fills the gap by checking data from APIs, user input, and external sources before the rest of your code trusts it. Type guards, assertion functions, and validation libraries each serve different needs. The key principle is simple: validate at the boundary, then trust the types everywhere inside.
More in this topic
TypeScript Compiler Explained
The TypeScript compiler (tsc) turns .ts files into .js files while checking types. Learn what it does, how to use it, and how it fits your workflow.
Set Up ESLint for TypeScript
Install and configure ESLint with typescript-eslint to catch bugs and enforce consistent code style in your TypeScript project.
Pick Utility Type in TypeScript
Pick creates a new type by selecting only the properties you name from an existing type. Use it to build focused types for specific views, API responses, or function parameters.