Composing types: union and intersection
- Union (
|): "one or the other". - Intersection (
&): "both at once".
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)
Partial<T>: makes all properties ofToptional.Pick<T, K>: selects a subset of keys.Omit<T, K>: removes a subset of keys.
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.