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.

7 min read

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.

Runtime validation at the boundary

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.

typescripttypescript
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.

typescripttypescript
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.

typescripttypescript
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.

typescripttypescript
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.

typescripttypescript
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.

PatternReturns on successReturns on failureType narrowed
Type guardtruefalseInside if block
AssertionvoidthrowsAfter 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.

typescripttypescript
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.

typescripttypescript
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.

typescripttypescript
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.

typescripttypescript
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.

typescripttypescript
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:

typescripttypescript
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

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.
RunePowered by Rune AI

Frequently Asked Questions

Why do I need runtime validation if TypeScript checks types at compile time?

TypeScript types are completely erased when the code compiles to JavaScript. Data from APIs, user input, localStorage, and file reads arrives at runtime with no type information. TypeScript cannot check the shape of data it cannot see at compile time.

What is the difference between a type guard and an assertion function?

A type guard returns a boolean and narrows the type inside an if block. An assertion function throws if the check fails and narrows the type for all code after it. Use type guards for branching logic and assertions when invalid data should stop execution.

Should I write validation functions by hand or use a library?

Start with hand-written type guards for simple shapes. When validation becomes repetitive or your schemas are large, use a library like Zod that generates both the TypeScript type and the runtime validator from a single schema definition.

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.