DevPath · Learn to code ESPTEN

Node with TypeScript

Useful types and the runtime boundary

Composing types: union and intersection

type Status = "active" | "inactive" | "suspended";   // union of literals

type WithId = { id: number };
type WithName = { name: string };
type Entity = WithId & WithName;                      // { id: number; name: string }

Utility types

TypeScript ships utility types that transform other types. From a User you can derive variants without rewriting fields:

interface User { id: number; name: string; email: string; }

type PartialUser = Partial<User>;            // all fields optional (ideal for a PATCH)
type PublicOnly  = Pick<User, "id" | "name">;   // only id and name
type WithoutId   = Omit<User, "id">;         // all but id (a creation DTO)

Type guards (x is T)

A type guard is a function that returns a boolean and, through the predicate x is T, tells the compiler: "if this is true, then x is of type T from here on".

function isUser(x: unknown): x is User {
  return (
    typeof x === "object" && x !== null &&
    typeof (x as any).name === "string" &&
    typeof (x as any).age === "number"
  );
}

function process(data: unknown) {
  if (isUser(data)) {
    // here data is User: the editor autocompletes data.name, data.age
    console.log(data.name.toUpperCase());
  }
}

Notice that the body of the guard is normal JavaScript checks (typeof, !== null): they run at runtime. The only special thing is the : x is User in the signature, which is information for the compiler.

The boundary: compile time vs runtime

This is the most important idea of the module. Remember that types are erased. That's why a type does NOT validate external data:

app.post("/users", (req, res) => {
  const dto = req.body as CreateUserDTO;   // ⚠️ A LIE: it's just a promise
  // At runtime req.body is whatever the client sent. It could be
  // { name: 42 } or {} or "text". TypeScript checks nothing at runtime.
});

An as (type assertion) tells the compiler "trust me", but it runs no check. If the client lies, your code blows up later with corrupt data.

Validating at runtime: Zod

The solution is to validate the input data at runtime with a library like Zod, which joins the two halves: you define a schema that validates at runtime and from which TypeScript infers the static type.

import { z } from "zod";

const CreateUserSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  age: z.number().int().positive().optional(),
});

// The type is DERIVED from the schema: a single source of truth
type CreateUserDTO = z.infer<typeof CreateUserSchema>;

app.post("/users", (req, res) => {
  const parsed = CreateUserSchema.safeParse(req.body);
  if (!parsed.success) {
    return res.status(400).json({ error: "Invalid data" });
  }
  const dto = parsed.data;   // dto: CreateUserDTO, already validated at runtime
  // ...
});

Golden rule: types protect the internal boundaries of your code (at compile time). At the external boundaries —HTTP, database, JSON.parse, environment variables— you must validate at runtime, because there the types no longer exist.

Put this into practice

DevPath is a hands-on course: you read the theory here; in the app you put it into practice with exercises that really run, offline.

Start free in the app →
← Typing an Express APIView the module →