PGH Web

PGH Web

Web Solutions from Pittsburgh

Your TypeScript Types Don't Exist at Runtime

Jeff Straney·

I typed an environment variable as string. I was certain it was always set because we set it in every environment. At some point it wasn't set in a test environment that ran against production infrastructure, and the code that used it passed undefined into a function that expected a string, and the error came out three layers down as something completely unrelated to the missing variable.

It took two hours to trace. The TypeScript types had told me the value would be a string. TypeScript was correct that if the value existed, it would be a string. But at runtime, the value didn't exist, and TypeScript had no opinion about that because TypeScript was not there.

What "compile-time only" means in practice

TypeScript compiles to JavaScript. The compiled JavaScript contains no type information. The runtime is JavaScript. TypeScript's job is to find errors before compilation, not to enforce types at runtime.

This is the right design. The alternative, runtime type enforcement built into the language, would be slower and harder to interop with the rest of the JavaScript ecosystem. But it means there is a gap, and the gap is at every boundary where data enters your system from somewhere TypeScript cannot see.

Those boundaries are: external APIs, environment variables, database results, user input, and JSON.parse. At all of these, the TypeScript type you've declared is a claim about what you expect, not a fact about what you'll get.

The specific cases that produce production bugs

External API responses are the most common. You declare a type based on the documentation. The API adds a field, removes a field, sometimes returns null where you expected a string, or has a bug that returns an entirely different shape for certain edge cases. Your TypeScript types don't notice. The code at runtime encounters an unexpected shape and fails in whatever way undefined access produces, which is usually not a helpful error message.

Environment variables are always string | undefined from the perspective of process.env. TypeScript lets you declare process.env.DATABASE_URL as string, and then you've told TypeScript to trust you. If you're wrong, the runtime is on its own.

JSON.parse returns any. The type assertion you add after it is a pinky promise that nothing in the system enforces. A config file that someone edited by hand, a message from a queue, a cached value from Redis: all any, all unsafe to pass through typed code as if they've been verified.

What to do about it

The answer is validation at the boundary. Before data from outside your system enters your typed code, something has to check that it is actually what you claimed it is.

Zod is the library I use for this. The design matches the problem: you define a schema, you call parse or safeParse on the incoming data, and you get a typed result or a structured error. The validation happens at runtime, where the data is.

import { z } from "zod";

const ApiResponse = z.object({
  id: z.number(),
  name: z.string(),
  status: z.enum(["active", "inactive"]),
  metadata: z.record(z.string()).optional(),
});

type ApiResponse = z.infer<typeof ApiResponse>;

async function fetchUser(id: number): Promise<ApiResponse> {
  const response = await fetch(`/api/users/${id}`);
  const data = await response.json();
  return ApiResponse.parse(data); // throws if the shape is wrong
}

If the API returns something that doesn't match the schema, parse throws with a description of what was wrong. You find out at the boundary, not three call frames later when the code tries to use a property that isn't there.

For environment variables, the same pattern:

const EnvSchema = z.object({
  DATABASE_URL: z.string().url(),
  PORT: z.coerce.number().default(3000),
  NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
});

const env = EnvSchema.parse(process.env);
export { env };

Parse this once at startup. If any required variable is missing or wrong, the application fails immediately at launch with a clear message. The rest of the application imports env and has a typed, validated object rather than raw process.env with its string | undefined values.

Where not to put validation

Not everywhere. The cost of validation is complexity and a small runtime overhead. You don't need it on data flowing through your own typed functions because TypeScript handles that correctly at compile time.

The rule: validate at the boundary where data enters the system. Not at every function call. Not redundantly inside already-typed code. At the specific points where data is coming from somewhere TypeScript cannot see.

Most systems have five or ten of these boundaries. Identifying them and putting a schema at each one is a half-day of work that prevents the category of bug where TypeScript said everything was fine and the runtime disagreed. TypeScript tells you about the contract you expect. Something else has to check whether the contract was honored.