Item 74: Know How to Reconstruct Types at Runtime

This is a sample item from Chapter 9 of the second edition of Effective TypeScript, which was released in May of 2024. It explains your options when you need to access a type at runtime, for example to perform request validation. If you like what you read, consider buying a copy of the book!

The item tries to be balanced in presenting the pros and cons of each approach. But in my own projects, I avoid Zod and use the third option instead (Generate Runtime Values from Your Types). I like TypeScript, and I'd like to define my types using its syntax!

At some point in the process of learning TypeScript, most developers have an epiphany when they realize that TypeScript types aren't "real": they're erased at runtime. This might be accompanied by a feeling of dread: if the types aren't real, how can you trust them?

The independence of types from runtime behavior is a key part of the relationship between TypeScript and JavaScript (Item 1). And most of the time this system works very well. But there are undeniably times when it would be extremely convenient to have access to TypeScript types at runtime. This item explores how this situation might arise and what your options are.

Imagine you're implementing a web server and you define an API endpoint for creating a comment on a blog post. You define a TypeScript type for the request body:

interface CreateComment {
postId: string;
title: string;
body: string;
}

Your request handler should validate the request. Some of this validation will be at the application level (does postId reference a post that exists and that the user can comment on?), but some will be at the type level (does the request have all the properties we expect, are they of the right type, and are there any extra properties?).

Here's what that might look like:

app.post('/comment', (request, response) => {
const {body} = request;
// 🛑 Don't do validation this way!
if (
!body ||
typeof body !== 'object' ||
Object.keys(body).length !== 3 ||
!('postId' in body) || typeof body.postId !== 'string' ||
!('title' in body) || typeof body.title !== 'string' ||
!('body' in body) || typeof body.body !== 'string'
) {
return response.status(400).send('Invalid request');
}
const comment = body as CreateComment;
// ... application validation and logic ...
return response.status(200).send('ok');
});

This is already a lot of validation code, even with just three properties. Worse, there's nothing to ensure that the checks are accurate and in sync with our type. Nothing checks that we spelled the properties correctly. And if we add a new property, we'll need to remember to add a check, too.

This is code duplication at its worst. We have two things (a type and validation logic) that need to stay in sync. It would be better if there was a single source of truth. The interface seems like the natural source of truth, but it's erased at runtime so it's unclear how you'd use it in this way.

Let's look at a few possible solutions to this conundrum.

Generate the Types from Another Source

If your API is specified in some other form, perhaps using GraphQL or an OpenAPI schema, then you can use that as the source of truth and generate your TypeScript types from it.

This typically involves running an external tool to generate types and, possibly, validation code. An OpenAPI spec uses JSON Schema, for example, so you can use a tool like json-schema-to-typescript to generate the TypeScript types, and a JSON Schema validator such as Ajv to validate requests.

The downside of this approach is that it adds some complexity and a build step that must be run whenever your API schema changes. But if you're already specifying your API using OpenAPI or some other system, then this has the enormous advantage of not introducing any new sources of truth, and this is the approach that you should prefer.

If this is a good fit for your situation, then Item 42 of Effective TypeScript includes an example of generating TypeScript types from a schema.

Define Types with a Runtime Library

TypeScript's design makes it impossible to derive runtime values from static types. But going the other direction (from a runtime value to a static type) is straightforward using the type-level typeof operator:

const val = { postId: '123', title: 'First', body: 'That is all'};
type ValType = typeof val;
// ^? type ValType = { postId: string; title: string; body: string; }

So one option is to define your types using runtime constructs and derive the static types from those. This is typically done using a library. There are many of these, but at the moment the most popular is Zod (React's PropTypes is another example).

Here's how the request validation logic would look with Zod:

import { z } from 'zod';

// runtime value for type validation
const createCommentSchema = z.object({
postId: z.string(),
title: z.string(),
body: z.string(),
});

// static type
type CreateComment = z.infer<typeof createCommentSchema>;
// ^? type CreateComment = { postId: string; title: string; body: string; }

app.post('/comment', (request, response) => {
const {body} = request;
try {
const comment = createCommentSchema.parse(body);
// ^? const comment: { postId: string; title: string; body: string; }
// ... application validation and logic ...
return response.status(200).send('ok');
} catch (e) {
return response.status(400).send('Invalid request');
}
});

Zod has completely eliminated the duplication: the value createCommentSchema is now the source of truth, and both the static type CreateComment and the schema validation (createCommentSchema.parse) are derived from that.

Zod and the other runtime type libraries are quite effective at solving this problem. So what are the downsides to using them?

  • You now have two ways to define types: Zod's syntax (z.object) and TypeScript's (interface). While these systems have many similarities, they're not exactly the same. You're already using TypeScript, so presumably your team has committed to learning how to define types using it. Now everyone needs to learn to use Zod as well.
  • Runtime type systems tend to be contagious: if createCommentSchema needs to reference another type, then that type will also need to be reworked into a runtime type. This may make it hard to interoperate with other sources of types, for example, if you wanted to reference a type from an external library or generate some types from your database using a tool like PgTyped or pg-to-ts.

Having a distinct runtime type validation system also comes with a few advantages:

  • Libraries like Zod can express many constraints that are hard to capture with TypeScript types, for example, "a valid email address" or "an integer." If you don't use a tool like Zod, you'll have to write this sort of validation yourself.
  • There's no additional build step. Everything is done through TypeScript. If you expect your schema to change frequently, then this will eliminate a failure mode and tighten your iteration cycle.

Generate Runtime Values from Your Types

If you're willing to introduce a new tool and build step, then there's another possibility: you can reverse the approach from the previous section and generate a runtime value from your TypeScript type. JSON Schema is a popular target.

To make this work we'll put our API types in an api.ts file:

// api.ts
export interface CreateComment {
postId: string;
title: string;
body: string;
}

then we can run typescript-json-schema to generate JSON Schema for this type:

$ npx typescript-json-schema api.ts '*' > api.schema.json

Here's what that file looks like:

{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"CreateComment": {
"type": "object",
"properties": {
"body": { "type": "string" },
"postId": { "type": "string" },
"title": { "type": "string" }
}
}
}
}

Now we can load api.schema.json at runtime. If you enable TypeScript's resolveJsonModule option, this can be done with an ordinary import. You can perform validation using any JSON Schema validation library. Here we use the Ajv library:

import Ajv from 'ajv';

import apiSchema from './api.schema.json';
import {CreateComment} from './api';

const ajv = new Ajv();

app.post('/comment', (request, response) => {
const {body} = request;
if (!ajv.validate(apiSchema.definitions.CreateComment, body)) {
return response.status(400).send('Invalid request');
}
const comment = body as CreateComment;
// ... application validation and logic ...
return response.status(200).send('ok');
});

The great strength of generating values from your TypeScript types is that you can continue to use all the TypeScript tools you know and love to define your types. You don't need to learn a second way to define types since the JSON Schema is an implementation detail. Your API types can reference types from @types or other sources since they're just TypeScript types.

The downside is that you've introduced a new tool and a new build step. Whenever you change api.ts, you'll need to regenerate api.schema.json. In practice, you'd want to enforce that these stay in sync using your continuous integration system.

While you don't typically need to access TypeScript types at runtime, there are occasionally situations like input validation where it's extremely useful. We've seen three approaches to this problem. So which one should you choose?

Unfortunately, there's no perfect answer. Each option is a trade-off. If your types are already expressed in some other form, like an OpenAPI schema, then use that as the source of truth for both your types and your validation logic. This will incur some tooling and process overhead, but it's worth it to have a single source of truth.

If not, then the decision is trickier. Would you rather introduce a build step or a second way to define types? If you need to reference types that are only defined using TypeScript types (perhaps they're coming from a library or are generated), then generating JSON Schema from your TypeScript types is the best option. Otherwise, you need to pick your poison!

Things to Remember

  • TypeScript types are erased before your code is run. You can't access them at runtime without additional tooling.
  • Know your options for runtime types: using a distinct runtime type system (such as Zod), generating TypeScript types from values (json-schema-to-typescript), and generating values from your TypeScript types (typescript-json-schema).
  • If you have another specification for your types (e.g., a schema), use that as the source of truth.
  • If you need to reference external TypeScript types, use typescript-json-schema or an equivalent.
  • Otherwise, weigh whether you prefer another build step or another system for specifying types.
Like this post? Consider subscribing to my newsletter, the RSS feed, or following me on Twitter.
Effective TypeScript Book Cover

Effective TypeScript shows you not just how to use TypeScript but how to use it well. Now in its second edition, the book's 83 items help you build mental models of how TypeScript and its ecosystem work, make you aware of pitfalls and traps to avoid, and guide you toward using TypeScript’s many capabilities in the most effective ways possible. Regardless of your level of TypeScript experience, you can learn something from this book.

After reading Effective TypeScript, your relationship with the type system will be the most productive it's ever been! Learn more »