ionTrails IT Consulting
EN
TypeScript & Zod

Best Way to Enforce Type-Safety at Runtime in TypeScript

Published on December 10, 2022 by Viliam Simko · Updated May 23, 2025

These days, frontend and backend developers often deal with many external APIs, typically REST APIs. Fetching data is easy, but making sure the data is valid and conforms to an expected schema can get messy.

External APIs can change over time or occasionally return garbage data—especially on Friday afternoons or during a lunar eclipse.

It is therefore important to detect issues quickly and reliably and prevent error propagation to the deeper parts of our software architecture. Imagine your trading bot relies on a stock-market API: you want to make sure all API responses are thoroughly checked before making any trading decisions.

High Expectations

Since I'm a spoiled TypeScript developer, I expect my API calls to be type-safe and my IDE to indulge me with autocompletion. I might use a code generator now and then, if I have a proper description of the API. The code generator can wrap data fetching into nicely typed functions and implement type checking to some degree. However, this isn't always possible.

What if I want to implement my own code generator? What if I'm hacking together a client for an API that wasn't originally intended to be an API?

The Problem: Compile-Time vs Runtime

TypeScript provides excellent developer ergonomics and compile-time guarantees. However, TypeScript types are erased at runtime. Data coming from an external API, a user form, a database, or another system is just runtime data. TypeScript cannot verify that it actually matches your declared interface.

Let's start from scratch

const response = await fetch("https://jsonplaceholder.typicode.com/users/1");
const result = await response.json();

console.log(result.email); // no autocompletion because result: any
console.log((result as User).email); // autocompletion, but no runtime validation

Here, for autocompletion to work, the response must be somehow cast to User. Sadly, every time we use explicit casting, a baby unicorn dies a slow death.

What we actually want is to save the unicorns ensure that the data conforms to a given schema before using it.

Blind type casting is not validation. The expression data as User tells the TypeScript compiler what you believe the data is; it does not inspect the runtime value.

Type Guards

TypeScript gives us a handy feature called type guards. Type guards are functions that take an input parameter and return a boolean indicating whether the input parameter satisfies a particular type. In our case, we define a function isUser with a special return type: data is User.

const isUser = (data: any): data is User =>
  (data as User).email !== undefined; // just for demonstration purposes

Here's where the magic happens: the TypeScript compiler and our IDE are clever enough to treat the variable as User inside the successful branch.

if (isUser(user)) {
  console.log(user.email); // autocompletion works because user is narrowed to User
} else {
  console.error("This is no User!");
}

This is already a significant improvement: we achieved schema checking, got autocompletion, and did not need to use an exception merely to express that the input was invalid.

Well, unicorns are saved, but what about the poor lazy programmers? They now have to write all these fancy type guards. If only we could automate this somehow…

Parse, Don't Validate

There's an interesting perspective: instead of validating data throughout our application, we should parse it at the boundary of the system.

A parser consumes less-structured input and produces more-structured output. Checks can be performed up-front at the boundary between a program and the outside world. Once those checks have been performed, they never need to be checked again.

The practical idea is simple: take untrusted external data, parse it once, and turn it into a well-defined internal representation. From that point onward, the rest of the application can work with the parsed result instead of repeatedly checking the same assumptions.

For TypeScript and JavaScript, several libraries can help with this approach, including Superstruct, Runtypes, io-ts, Joi, and Zod.

Zod

Zod is a TypeScript-first schema validation library that provides runtime validation of data against defined schemas. We describe our data structure in a composable way and get a parser that validates input at runtime. If the input is valid, the parser returns a statically typed value with full TypeScript autocomplete support.

A useful property of Zod is that the runtime schema can also become the single source of truth for the corresponding TypeScript type.

import { z } from "zod";

const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1),
  email: z.string().email(),
});

type User = z.infer<typeof UserSchema>;

This avoids maintaining a separate interface and runtime validation definition that can eventually drift apart. The schema describes what is accepted at runtime, while z.infer derives the corresponding static TypeScript type.

Parsing with Zod

Once the schema is defined, we can parse incoming data. The simplest option is parse(): it returns the typed value when the input is valid and throws when it is not.

const result = UserSchema.parse(input);

console.log(result.email); // result is typed as User

When invalid input is an expected possibility, safeParse() provides a more explicit control flow without throwing an exception.

const result = UserSchema.safeParse(input);

if (!result.success) {
  console.error("Invalid user payload", result.error);
  return;
}

console.log(result.data.email); // result.data is User

Zod can also provide detailed validation errors, including the path to the invalid part of a nested structure. This makes it useful not only for rejecting invalid input, but also for producing useful diagnostics at system boundaries.

Where Should Runtime Parsing Happen?

The most useful place for runtime parsing is at the boundary where untrusted or externally controlled data enters your application: API responses, request payloads, configuration, user input, or similar sources.

Once the data has successfully passed through the parser, the resulting value can be treated as the application's internal representation. This keeps validation concerns close to the boundary instead of spreading ad-hoc checks throughout the codebase.

Summary & Best Practices

Conclusion

The important distinction is between what TypeScript can guarantee at compile time and what your application actually receives at runtime. External data should not be trusted simply because you assigned it a TypeScript type.

A good boundary is therefore: receive the data, parse it, handle invalid input, and then let the rest of the application work with the verified result.

In practice, a library such as Zod makes this approach convenient by combining runtime schemas with TypeScript's static type system.

← Back to all articles