TypeScript became the #1 most-used language on GitHub in August 2025, passing Python. It sits among the top languages in every major developer survey — 43.6% of developers use it, according to the 2025 Stack Overflow survey. Every major framework supports it: React, Next.js, Angular, Vue 3, and Node.js all ship first-class TypeScript support. Microsoft, Google, Meta, Airbnb, Stripe, and Vercel run production systems on it.

TypeScript is a superset of JavaScript that adds static types. Every valid JavaScript file is already valid TypeScript. You add type annotations, and the compiler checks your code for mistakes before you run it — a type error surfaces in your editor instead of in production.

This guide takes you from tsc --version to a working project: a type-safe CLI bookmark manager built with Commander, Zod, and chalk. Every section adds a concept, and the final part connects them into one project.

What you will have by the end: a solid grip on TypeScript’s type system — narrowing, generics, mapped and conditional types — comfort using TypeScript with React, Node.js, and Next.js, and a working CLI tool with runtime-validated, type-safe commands.

Two things before you start:

  • Basic JavaScript knowledge helps, but this guide explains every TypeScript-specific concept from the ground up.
  • Full source code for the capstone project is on GitHub: github.com/kemalcodes/typescript-tutorial.

Part 1: Foundations

Type Annotations and Inference

A type annotation tells TypeScript what type a variable should be:

let name: string = "Alex";
let age: number = 25;

TypeScript is smart enough to infer types from the value you assign, so you rarely need annotations on local variables:

let city = "Berlin"; // inferred as string, no annotation needed

Add annotations where TypeScript cannot infer anything — function parameters, mainly:

function greet(name: string): string {
  return "Hello, " + name;
}

Without a type, strict mode rejects the parameter: Parameter 'name' implicitly has an 'any' type.

The Type Vocabulary

TypeScript’s number type covers integers and decimals alike — there is no separate int/float. Arrays come in two equivalent forms, number[] and Array<number>; almost everyone uses the shorter one. A tuple is an array with a fixed length where each slot has its own type — [string, number] — useful for a function returning multiple values without a full interface. Since TypeScript 4.0, tuple slots can carry a label purely for readability, with no effect on behavior: type UserInfo = [name: string, age: number, active: boolean] reads far better at a call site than three bare positional types.

any turns off type checking entirely — avoid it, it defeats the point of TypeScript. unknown is the safe alternative: it accepts anything but forces you to narrow the type before you can use it:

let value: unknown = "hello";
console.log(value.toUpperCase()); // Error: 'value' is of type 'unknown'

if (typeof value === "string") {
  console.log(value.toUpperCase()); // OK — narrowed to string
}

Rule: use unknown instead of any, alwaysany should appear only as a temporary step while migrating a JavaScript file.

void marks a function that returns nothing; never marks one that never returns at all — it always throws, or loops forever. With strict: true, null and undefined are their own types, not silently assignable to string. Use optional chaining (user?.email) to read through a value that might be null without crashing, and nullish coalescing (input ?? "default") to supply a fallback only when the left side is null or undefined — unlike ||, it does not trigger on 0 or "".

Functions

Parameters need explicit types; return types are usually inferred and only worth writing explicitly on public APIs. A parameter marked with ? is optional and implicitly includes undefined in its type — it must come after every required parameter. A parameter with = value is a default parameter, which is different: its type never includes undefined, and it does not have to be last.

function greet(name: string, greeting: string = "Hello"): string {
  return `${greeting}, ${name}!`;
}

Rest parameters (...numbers: number[]) collect any number of trailing arguments into an array, and must be the final parameter. Overloads let one function return different types depending on the input type:

function first(arr: string[]): string;
function first(arr: number[]): number;
function first(arr: (string | number)[]): string | number {
  return arr[0];
}

TypeScript matches your call against the overload signatures, not the implementation signature underneath. If you can express the same thing with a generic (Part 2), prefer that — it’s simpler.

Callback parameters get contextual typing: when a function parameter is typed via a type alias like type FilterFn = (item: string) => boolean, the callback you pass doesn’t need its own annotations — TypeScript infers them from the alias.

Objects and Interfaces

An inline object type ({ name: string; age: number }) works but gets repetitive. An interface names a reusable shape:

interface User {
  name: string;
  age: number;
  email?: string;   // optional — includes undefined
  readonly id: number; // compile-time only; JS can still mutate it at runtime
}

Interfaces extends other interfaces, including multiple at once, to compose small shapes into bigger ones. type aliases do the equivalent with & (intersection). The practical difference table:

Featureinterfacetype
Extendextends& intersection
Declaration mergingYesNo
Unions, tuples, primitivesNoYes

Simple rule: use interface for object shapes you might extend or that classes will implement; use type for unions, tuples, and everything else.

Declaration merging is interface’s one genuinely unique trick — declare the same interface name twice and TypeScript combines the members. It’s mostly used to add fields to a third-party type (e.g. Express.Request), not in everyday code.

Two behaviors catch people off guard. Excess property checking only fires when you assign an object literal directly to a typed variable — pass the same data through an intermediate const, and the check silently disappears, because TypeScript uses structural typing: an object satisfies an interface if it has the required properties, extra ones or not.

interface User { name: string; age: number; }

const user: User = { name: "Alex", age: 25, email: "x" }; // Error: excess property

const data = { name: "Alex", age: 25, email: "x" };
const user2: User = data; // OK — no literal, no check

An index signature ([key: string]: string) types objects whose keys aren’t known ahead of time, like a translation dictionary. Combine it with named properties for a config object with a few required fields plus arbitrary extras — every named property’s type must be compatible with the index signature’s value type, so name: string fits under [key: string]: string | number but a boolean field would not:

interface Config {
  name: string;
  version: number;
  [key: string]: string | number; // any other key, string or number value
}
const config: Config = { name: "my-app", version: 1, port: 3000, host: "localhost" };

Part 2: The Type System In Depth

Unions, Literals, and Discriminated Unions

A union type (string | number) means “one of these.” You can only call methods that exist on every member of the union until you narrow it. A literal type narrows further, to one exact value: "up" | "down" | "left" | "right" accepts nothing else.

The pattern that makes unions powerful is the discriminated union — every variant shares one literal-typed property (the discriminant), so a switch on that property narrows the rest of the shape automatically:

type Circle = { kind: "circle"; radius: number };
type Rectangle = { kind: "rectangle"; width: number; height: number };
type Shape = Circle | Rectangle;

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle": return Math.PI * shape.radius ** 2;
    case "rectangle": return shape.width * shape.height;
  }
}

Force TypeScript to catch a forgotten case by adding a default branch that assigns to never — if a new variant is added to Shape later and this switch isn’t updated, the assignment stops compiling:

function assertNever(value: never): never {
  throw new Error(`Unhandled: ${JSON.stringify(value)}`);
}
// default: return assertNever(shape);

Intersection types (A & B) require both shapes at once — the opposite of a union:

type HasName = { name: string };
type HasEmail = { email: string };
type Contact = HasName & HasEmail; // must have both name AND email

Useful for composing small, focused mixins into one type without inheritance — the same relationship interface Contact extends HasName, HasEmail expresses for interfaces.

Enums, as const, and satisfies

A numeric enum auto-increments from 0 unless you set a starting value, and TypeScript generates a reverse mapping for it — the compiled object maps both name→number and number→name:

enum HttpStatus { OK = 200, Created = 201, NotFound = 404 }
console.log(HttpStatus.NotFound); // 404
console.log(HttpStatus[404]);     // "NotFound" — reverse mapping, numeric enums only

A string enum has no auto-increment — every member needs its own value — and no reverse mapping, but reads far better in logs ("ACTIVE" tells you something at a glance; 0 does not). A const enum is inlined at compile time, so the enum object never exists at runtime — Direction.Up compiles straight to 0 — which shrinks bundles but breaks reverse mapping, iteration, and support in some bundlers (esbuild does not implement const enum). Modern TypeScript leans away from enums, toward union types and as const:

const Status = { Active: "ACTIVE", Inactive: "INACTIVE" } as const;
type Status = (typeof Status)[keyof typeof Status]; // "ACTIVE" | "INACTIVE"

This gives named constants, a union type, and zero runtime overhead — the object is a plain JS object, unlike an enum which generates real code. as const narrows a literal to its most specific type and makes it deep-readonly, but only at compile time (Object.freeze is the runtime-enforced version).

The satisfies operator (TS 4.9+) validates an object against a type without widening it the way a type annotation does — you keep both the shape-check and the literal inference:

type Theme = { colors: Record<string, string> };
const theme = { colors: { primary: "#3498db" } } satisfies Theme;
theme.colors.primary; // TypeScript still knows this key exists

Narrowing

typeof narrows primitives, with one classic trap: typeof null === "object", a JavaScript bug from 1995 that was never fixed. Truthiness narrowing has its own trap — if (count) treats 0 the same as null, so an explicit count !== null is safer for numbers that might legitimately be zero. instanceof narrows by class, and in narrows by checking whether a property exists — useful when instanceof isn’t available, like plain objects from JSON.

in narrows by checking whether a property exists, without needing a class or instanceof — the natural fit for two plain object shapes that don’t share a discriminant:

type Fish = { swim: () => void };
type Bird = { fly: () => void };
function move(animal: Fish | Bird) {
  if ("swim" in animal) animal.swim(); // narrowed to Fish
  else animal.fly();                    // narrowed to Bird
}

Custom type guards use the is return type to teach TypeScript a new narrowing rule, useful once the same check is needed in more than one place:

function isFish(animal: Fish | Bird): animal is Fish {
  return "swim" in animal;
}

Assertion functions are the throwing cousin — asserts value is string narrows the type for the rest of the block if the function doesn’t throw, instead of returning a boolean:

function assertDefined<T>(value: T | null | undefined): asserts value is T {
  if (value == null) throw new Error("Value is null or undefined");
}

Generics

A generic function keeps type information that any would throw away:

function first<T>(arr: T[]): T | undefined {
  return arr[0];
}
const num = first([1, 2, 3]);  // number | undefined — not any

Interfaces, type aliases, and classes can all be generic. A generic API-response wrapper is the pattern you’ll reach for constantly in a real backend — one shape, reused for every endpoint’s payload type:

interface ApiResponse<T> { data: T; status: number; message: string; }
const userResponse: ApiResponse<User> = { data: { id: 1, name: "Alex" }, status: 200, message: "OK" };

Another built-in pattern is Result<T, E>, borrowed from Rust, for making failure explicit in the return type instead of throwing:

type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };

Constraints (T extends { length: number }) restrict what a type parameter can be. The most common constraint pairing is keyof, which produces a union of a type’s property names, combined with a type parameter to build type-safe property accessors:

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

Default type parameters (interface Container<T = string>) let most callers omit the generic entirely.

Classes

TypeScript adds three access modifiers on top of JavaScript classes: public (default, accessible everywhere), protected (class and subclasses), private (class only — but only at compile time; use JS’s real #field syntax for runtime-enforced privacy). readonly blocks reassignment after construction.

Parameter properties collapse “declare a field, then assign it in the constructor” into one line:

class User {
  constructor(
    public readonly id: number,
    public name: string,
    private password: string,
  ) {}
}

abstract class cannot be instantiated directly and can mix abstract methods (declared, not implemented — subclasses must) with regular ones. Compared to an interface, an abstract class can hold implementation and a constructor, but only supports single inheritance where an interface supports implementing many at once:

Abstract classInterface
ImplementationCan haveCannot
Multiple inheritanceNo (single extends)Yes (multiple implements)
Runtime existenceYesNo (erased)

abstract class shines when several concrete classes share behavior but each implements a couple of methods differently — the abstract members are a contract, the concrete methods are shared code, in one declaration:

abstract class Shape {
  abstract getArea(): number; // no body — every subclass must implement it
  describe(): string { return `Area: ${this.getArea().toFixed(2)}`; } // shared, inherited as-is
}
class Circle extends Shape {
  constructor(private radius: number) { super(); }
  getArea(): number { return Math.PI * this.radius ** 2; }
}

Modern TypeScript, especially React and Node.js code, often prefers plain functions and objects over classes — reach for a class when you need inheritance, instanceof, or a framework that expects one (Angular, NestJS). TypeScript 5.0+ also supports standard ECMAScript decorators (@log on a method) — you’ll see them constantly in NestJS and TypeORM, rarely need to write your own.

Part 3: Advanced Types, Modules, and Errors

Modules

Named exports (export function add) are the default choice — consistent names, easy auto-import, easy rename-refactoring. Default exports suit React components and config files. import type { User } from "./user" marks an import as type-only, so bundlers can strip it and it can’t create a runtime circular dependency. Two mistakes bite often: under "module": "NodeNext", Node’s ESM resolution requires the .js extension on relative imports even for .ts files (import { User } from "./user.js"), and mutually-importing files (a.tsb.ts) can leave an imported value undefined at runtime — fix it by extracting shared types to a third file or by making the import type-only.

Utility Types

TypeScript ships utility types that transform existing types instead of forcing you to redeclare them:

UtilityEffect
Partial<T>all properties optional — the shape of an update payload
Required<T>all properties required
Readonly<T>all properties readonly
Pick<T, K> / Omit<T, K>keep or drop specific keys — the standard way to build a public-safe PublicUser from a User with a password hash
Record<K, T>object type where every key maps to the same value type
Exclude<T, U> / Extract<T, U>remove or keep members of a union
NonNullable<T>strips null/undefined
ReturnType<T> / Parameters<T>pull a function’s return or parameter types without redeclaring them
Awaited<T>unwraps Promise<T>, including nested promises

They combine: type PublicUser = Readonly<Pick<User, "id" | "name">>. Omit is the mirror of Pick and the standard shape for “everything except the server-generated fields”:

interface User { id: number; name: string; email: string; passwordHash: string; createdAt: Date; }
type CreateUserInput = Omit<User, "id" | "passwordHash" | "createdAt">; // just name + email

Record<K, T> builds an object type where a union of keys all map to the same value type — a natural fit for exhaustive lookup tables the compiler checks for missing keys:

type StatusCode = 200 | 404 | 500;
const statusMessages: Record<StatusCode, string> = { 200: "OK", 404: "Not Found", 500: "Internal Server Error" };
// omitting one status code here is a compile error, not a silent undefined

Mapped and Conditional Types

A mapped type loops [K in keyof T] over every key of T and transforms it — this is literally how Partial is implemented internally:

type MyPartial<T> = { [K in keyof T]?: T[K] };
type MyRequired<T> = { [K in keyof T]-?: T[K] }; // -? strips the optional modifier

The as clause (TS 4.1+) remaps keys during the loop — a Getters<T> helper that turns every property into a matching getter method name is the canonical example, and it composes with the template literal types from the next section:

type Getters<T> = { [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K] };
type UserGetters = Getters<{ name: string; age: number }>;
// { getName: () => string; getAge: () => number }

Remapping a key to never drops it from the result entirely, which is how you’d build a “string properties only” filter over an arbitrary object type. A conditional type (T extends U ? X : Y) picks a type based on a condition, and infer extracts a type from inside another — this is how ReturnType and Awaited are implemented:

type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type UnwrapPromise<T> = T extends Promise<infer V> ? V : T;

A conditional type applied to a union distributes over each member by default (ToArray<string | number> becomes string[] | number[], not (string | number)[]) — wrap the checked type in a tuple ([T] extends [unknown]) when you need to treat the union as one thing instead. DeepPartial<T> — recursing into nested objects, which Partial alone does not do — is the utility type worth writing yourself once you understand this:

type DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] };

Template Literal Types

Template literal types apply the backtick syntax at the type level and, combined with unions, generate every combination automatically:

type Size = "small" | "large";
type Color = "red" | "blue";
type ClassName = `${Size}-${Color}`; // "small-red" | "small-blue" | "large-red" | "large-blue"

Built-in string transforms (Uppercase, Lowercase, Capitalize, Uncapitalize) pair well with this for generating a matching set of prop names from a set of event names:

type DOMEvent = "click" | "focus" | "blur";
type EventHandler = `on${Capitalize<DOMEvent>}`; // "onClick" | "onFocus" | "onBlur"
interface ButtonProps { onClick?: () => void; onFocus?: () => void; onBlur?: () => void; }

Watch the combinatorics — TypeScript caps template literal expansion around 100,000 combinations and errors past it, so keep the source unions small; use plain string with runtime validation once you need genuinely large sets.

Error Handling

You cannot write catch (error: SomeType) — TypeScript has no syntax for it, because JavaScript can throw literally anything. Since TS 4.4 (default under strict), the catch parameter’s type is unknown, not any, forcing a narrowing check before use:

try {
  JSON.parse(input);
} catch (error) {
  if (error instanceof Error) console.log(error.message);
  else console.log("Unknown error:", String(error));
}

Custom error classes (extends Error, set this.name) let a single catch branch on instanceof to route different failures differently — a NotFoundError becomes a 404, a ValidationError a 400, without the caller parsing message strings:

class NotFoundError extends Error {
  constructor(resource: string, id: string | number) {
    super(`${resource} with id ${id} not found`);
    this.name = "NotFoundError";
  }
}
class ValidationError extends Error {
  constructor(public field: string, message: string) {
    super(message);
    this.name = "ValidationError";
  }
}
// catch (error) { if (error instanceof NotFoundError) return res.status(404)...; }

The Result type pattern{ ok: true, value } | { ok: false, error } as a real return type — makes failure visible in the function signature, so a caller can’t forget to handle it the way they can forget a try/catch:

async function tryCatch<T>(promise: Promise<T>): Promise<Result<T, Error>> {
  try {
    return { ok: true, value: await promise };
  } catch (error) {
    return { ok: false, error: error instanceof Error ? error : new Error(String(error)) };
  }
}

Pick one pattern per layer and don’t mix them: Result types for business logic, try/catch at infrastructure boundaries (API calls, file I/O), discriminated unions for UI state.

Async/Await

Promise<T> types what a Promise resolves to; await unwraps it to T. Promise.all runs promises concurrently and preserves each result’s type in order — always prefer it over sequential awaits when the operations don’t depend on each other, since total time drops from the sum to the max:

async function loadDashboard() {
  const [user, products, orders] = await Promise.all([getUser(1), getProducts(), getOrders()]);
  // types preserved per-position: user is User, products is Product[], orders is Order[]
}

Promise.all rejects as soon as any input rejects. Promise.allSettled waits for everything regardless, returning {status: "fulfilled" | "rejected"} per item instead of failing the whole batch — the right choice when partial results are still useful. Promise.race resolves with whichever promise settles first, which is how a generic timeout wrapper is built — race the real work against a promise that rejects after a delay:

function fetchWithTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
  const timeout = new Promise<never>((_, reject) =>
    setTimeout(() => reject(new Error("Timeout")), timeoutMs)
  );
  return Promise.race([promise, timeout]);
}

AbortController cancels an in-flight fetch via its signal, for cleanup on unmount or a user-triggered cancel. Putting the pieces together — generics, the Result pattern, and AbortSignal — makes a small, typed fetch wrapper worth keeping in any project’s utility folder:

type ApiResult<T> = { ok: true; data: T; status: number } | { ok: false; error: string; status: number };

async function api<T>(url: string, options: RequestOptions = {}): Promise<ApiResult<T>> {
  try {
    const hasBody = options.body !== undefined;
    const response = await fetch(url, {
      method: options.method ?? "GET",
      headers: { ...(hasBody ? { "Content-Type": "application/json" } : {}), ...options.headers },
      body: hasBody ? JSON.stringify(options.body) : undefined,
      signal: options.signal,
    });
    if (!response.ok) return { ok: false, error: `${response.status}: ${response.statusText}`, status: response.status };
    return { ok: true, data: (await response.json()) as T, status: response.status }; // response.json() is NOT runtime-validated — parse with Zod if the shape matters
  } catch (error) {
    return { ok: false, error: error instanceof Error ? error.message : "Unknown error", status: 0 };
  }
}

The most common real bug in async code is a missing await — the variable silently holds a Promise<T> instead of T, and property access on it fails or produces garbage instead of a clear error.

Part 4: TypeScript With Real Frameworks

React

useState’s type comes from its initial value; give it an explicit generic when the state starts null (useState<User | null>(null)). useRef<HTMLInputElement>(null) types a DOM ref; access it with ?. because .current starts null. Type children as React.ReactNode (accepts strings, numbers, elements, arrays, null — nearly everything) rather than the narrower React.ReactElement. Most teams skip React.FC today — it used to auto-include children in props, which React 18 removed, so it adds nothing over typing props directly.

React events have specific types, not a generic Event:

EventType
onClickReact.MouseEvent<HTMLElement>
onChangeReact.ChangeEvent<HTMLInputElement>
onSubmitReact.FormEvent<HTMLFormElement>
onKeyDownReact.KeyboardEvent<HTMLElement>

Extend a native element’s own prop types (React.InputHTMLAttributes<HTMLInputElement>) to build a wrapper that accepts every real HTML attribute plus your own — spread the rest through so callers keep type, required, placeholder, and everything else for free:

interface CustomInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label: string;
  error?: string;
}
function CustomInput({ label, error, ...inputProps }: CustomInputProps) {
  return <div><label>{label}</label><input {...inputProps} />{error && <span>{error}</span>}</div>;
}

Generic components carry the item type through props, so a renderItem callback is fully typed without the caller writing any annotation:

interface ListProps<T> {
  items: T[];
  renderItem: (item: T) => React.ReactNode;
}
function List<T>({ items, renderItem }: ListProps<T>) { /* ... */ }

Node.js and Express

Express’s Request generic is Request<Params, ResBody, ReqBody, Query> — type route params, response body, request body, and query string independently:

app.post("/notes", (req: Request<{}, {}, CreateNoteInput>, res: Response) => {
  const { title, content } = req.body; // typed
});

Middleware types the same way, and error-handling middleware is the one place the parameter count matters — Express detects it by arity, so it must take exactly four parameters (err, req, res, next) and be registered last, after every route:

function errorHandler(err: Error, req: Request, res: Response, next: NextFunction): void {
  console.error(err.stack);
  res.status(500).json({ error: "Something went wrong" });
}
app.use("/notes", notesRouter);
app.use(errorHandler); // must be last

Middleware that attaches custom data (like req.userId after auth) needs declaration merging to extend Express’s own Request type globally:

declare global {
  namespace Express {
    interface Request { userId?: string; }
  }
}

Validate environment variables at startup rather than trusting process.env scattered through the codebase — see the Zod env-validation pattern below, which turns a runtime crash three requests in into an immediate, obvious boot failure.

Next.js

The App Router is file-based: page.tsx per route, layout.tsx wraps shared UI. Since Next.js 15, dynamic route params and searchParams are both Promises you must await, a breaking change from earlier versions:

interface PageProps { params: Promise<{ id: string }>; }
export default async function UserPage({ params }: PageProps) {
  const { id } = await params;
}

Server Components (the default, no directive needed) can be async and touch a database or API directly; Client Components ("use client" at the top of the file) cannot be async and must use hooks like useState/useEffect for data instead. Server Actions ("use server") let a form call server code directly, skipping a hand-written API route:

"use server";
export async function createNote(prevState: FormState, formData: FormData): Promise<FormState> {
  const title = formData.get("title") as string;
  // ...
}

Testing With Vitest

Vitest needs zero TypeScript config — it reads your tsconfig.json and reuses Vite’s transform pipeline, so there’s no ts-jest or Babel step to configure. vi.fn() creates a typed mock that tracks calls:

const mockFn = vi.fn();
mockFn("hello");
expect(mockFn).toHaveBeenCalledWith("hello");
expect(mockFn).toHaveBeenCalledTimes(1);

vi.mock("./api", () => ({...})) replaces an entire module with a typed stand-in — useful for isolating a unit under test from a real network call or database. One easy-to-miss detail: expectTypeOf assertions are no-ops unless you run Vitest in typecheck mode (vitest --typecheck) — without it, a wrong-type assertion silently passes at runtime instead of failing the test.

Zod: Runtime Validation

TypeScript types vanish at compile time — nothing stops an API from returning { name: 123 } into a variable typed User. Zod validates the actual data and derives the TypeScript type from the same schema, so they can never drift apart:

const UserSchema = z.object({
  name: z.string(),
  email: z.string().email(),
  age: z.number().min(0).max(150),
});
type User = z.infer<typeof UserSchema>;
const user = UserSchema.parse(await response.json()); // throws on mismatch

.safeParse() returns { success, data } or { success, error } instead of throwing — the same Result-style pattern from error handling. .transform() changes the parsed value, and chains — a slug schema can trim, lowercase, and clean invalid characters in one declaration:

const slugSchema = z.string().trim().toLowerCase()
  .transform((val) => val.replace(/\s+/g, "-"))
  .transform((val) => val.replace(/[^a-z0-9-]/g, ""));
slugSchema.parse("  Hello World! "); // "hello-world"

.refine() adds custom validation logic with its own error message. z.discriminatedUnion("type", [...]) gives Zod the same exhaustive-narrowing power as a TypeScript discriminated union, but enforced at runtime too.

Validating environment variables is the other place process.env needs Zod as much as any API response does — every value in process.env is string | undefined, and a typo’d or missing variable should fail loudly at boot, not three requests into production:

const EnvSchema = z.object({
  PORT: z.string().transform(Number).pipe(z.number().int().positive()),
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
});
export const env = EnvSchema.parse(process.env); // throws immediately on boot if anything is wrong

tRPC: End-to-End Type Safety

tRPC exposes server functions (“procedures”) to the client as regular typed function calls — no REST schema, no codegen. Define a router with Zod-validated inputs:

export const userRouter = router({
  getById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input }) => await findUser(input.id)), // return type inferred
  create: publicProcedure
    .input(z.object({ name: z.string().min(1), email: z.string().email() }))
    .mutation(async ({ input }) => await createUser(input)),
});

The server exports only a type (export type AppRouter = typeof appRouter); the client imports that type and calls trpc.user.getById.useQuery({ id }) with full inference on both the input and the returned data — no manually written REST endpoints, no shared schema file to keep in sync, no JSON parsing in client code. Input validation runs before your handler, so invalid requests never reach it. It’s the right tool for a full-stack TypeScript app where the same team owns both ends — not for a public API or a client written in another language, since tRPC has no client library outside TypeScript.

Part 5: Advanced Patterns, Configuration, and the Capstone Project

Branded Types

Plain type UserId = string and type OrderId = string compile identically, so swapping them at a call site is a silent bug — TypeScript sees no difference. A branded type attaches a fake, compile-time-only tag that makes them incompatible:

type Brand<T, B> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;

function createUserId(id: string): UserId { return id as UserId; }
getUser(createOrderId("order_456")); // Error — OrderId isn't assignable to UserId

The same trick prevents mixing currencies (USD/EUR both backed by number) or any two IDs that share an underlying primitive type.

TypeScript also ships readonly versions of the built-in collections — ReadonlyArray<T>, ReadonlyMap<K, V>, ReadonlySet<T> — which reject mutating methods (.push, .set, .add) at compile time while still allowing reads:

const numbers: ReadonlyArray<number> = [1, 2, 3];
// numbers.push(4); // Error: Property 'push' does not exist on type 'readonly number[]'

Readonly<T> from Part 3 only freezes the top level — nested objects underneath are still mutable. A DeepReadonly<T> recursive mapped type fixes that, walking into every nested object (and skipping functions, which have no properties to freeze):

type DeepReadonly<T> = T extends Function ? T : T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } : T;

Builder Pattern and Typed Events

A fluent builder (.from(table).select(...).where(...).build()) reads cleanly and, with a generic State type parameter tracking which steps ran, can make build() a compile error until the required steps were called.

A typed event emitter links event names to their payload type through keyof, so a wrong payload shape or a typo’d event name is a compile error, not a runtime surprise three files away:

class TypedEventEmitter<Events extends Record<string, unknown>> {
  private listeners = new Map<string, Set<Function>>();
  on<K extends keyof Events>(event: K, listener: (payload: Events[K]) => void): void {
    if (!this.listeners.has(event as string)) this.listeners.set(event as string, new Set());
    this.listeners.get(event as string)!.add(listener);
  }
  emit<K extends keyof Events>(event: K, payload: Events[K]): void {
    this.listeners.get(event as string)?.forEach((listener) => listener(payload));
  }
}

type EventMap = { userCreated: { id: string; name: string } };
const emitter = new TypedEventEmitter<EventMap>();
emitter.on("userCreated", (payload) => console.log(payload.name)); // payload is fully typed
// emitter.emit("userCreated", { id: "1" });   // Error: missing name
// emitter.emit("unknown", {});                 // Error: "unknown" is not a valid event

tsconfig.json Essentials

strict: true is the single most important setting — it bundles eight sub-flags that each catch a real class of bug on their own: strictNullChecks (null/undefined are separate types, not silently assignable everywhere), noImplicitAny (an untyped parameter is an error, not a silent any), strictFunctionTypes, strictBindCallApply, strictPropertyInitialization (a class field must be initialized or explicitly marked possibly-undefined), useUnknownInCatchVariables (Part 3’s catch behavior), noImplicitThis, and alwaysStrict. Leave the umbrella flag on in every project rather than picking sub-flags individually — a project that turns strict mode off “temporarily” rarely turns it back on. target picks the output JS version (ES2022 is the safe modern default for Node 20+). module/moduleResolution should be "NodeNext" for a Node backend or "Bundler" behind Vite/webpack/Next.js — Next.js sets sane defaults itself.

Project references split a monorepo into separate TypeScript projects that reference each other (packages/shared, packages/server, packages/client, each with its own tsconfig.json) — each package’s composite: true config lets tsc --build compile only what changed and its dependents, instead of the whole repo on every run.

isolatedModules ensures every file compiles independently, required by single-file compilers like esbuild, SWC, and Babel — it also forbids const enum and bare type re-exports. verbatimModuleSyntax requires import type for type-only imports, so the type/value boundary is explicit in the source instead of inferred. erasableSyntaxOnly (TS 5.8+) restricts you to syntax Node’s native --experimental-strip-types can erase by deletion alone — no enum, no parameter properties, no namespace with runtime values. Path aliases ("@/*": ["./src/*"]) only tell TypeScript where to look; your bundler needs its own matching alias config, or the code won’t actually run.

Capstone: A Type-Safe CLI Bookmark Manager

bm is a terminal bookmark manager — add, list, search, delete, export — that ties the whole guide together: Zod schemas for runtime-validated storage, a Result-free error path (safeParse + process.exit(1)), and Commander for argument parsing.

The schema is the single source of truth for both the on-disk JSON shape and the TypeScript type:

// src/types.ts
export const BookmarkSchema = z.object({
  id: z.string(),
  title: z.string().min(1),
  url: z.string().url(),
  tags: z.array(z.string()).default([]),
  createdAt: z.string().datetime(),
});
export type Bookmark = z.infer<typeof BookmarkSchema>;

Storage reads and validates in one step — a corrupt or hand-edited JSON file fails loudly instead of poisoning the app with malformed data:

// src/store.ts
function readStore(): BookmarkStore {
  try {
    return BookmarkStoreSchema.parse(JSON.parse(fs.readFileSync(STORE_PATH, "utf-8")));
  } catch {
    return { bookmarks: [] };
  }
}

The list command reuses one formatter for both “all bookmarks” and “filtered by tag,” since the only difference is which store function feeds it:

// src/commands/list.ts
export function handleList(options: { tag?: string }): void {
  const bookmarks = options.tag ? getBookmarksByTag(options.tag) : getAllBookmarks();
  if (bookmarks.length === 0) { console.log(chalk.yellow("No bookmarks found.")); return; }
  for (const bookmark of bookmarks) formatBookmark(bookmark); // shared formatter
}

Each command validates its own input with safeParse before touching storage:

// src/commands/add.ts
const result = AddBookmarkInput.safeParse({ title, url, tags });
if (!result.success) {
  for (const issue of result.error.issues) console.error(chalk.red(`Error: ${issue.message}`));
  process.exit(1);
}

delete and export round out the command set, and export’s format handling is a small real-world case of the union-type-plus-switch pattern from Part 2 — an unhandled format value falls through with no output, which is exactly the kind of gap an assertNever default branch would catch:

// src/commands/export.ts
type ExportFormat = "json" | "csv";
export function handleExport(options: { format: string }): void {
  const format = options.format as ExportFormat;
  const bookmarks = getAllBookmarks();
  switch (format) {
    case "json": console.log(JSON.stringify(bookmarks, null, 2)); break;
    case "csv": /* build CSV rows */ break;
  }
}

Commander wires commands to handlers declaratively in the entry point:

// src/index.ts
program.command("add")
  .argument("<title>", "Bookmark title")
  .argument("<url>", "Bookmark URL")
  .option("-t, --tags <tags>", "Comma-separated tags")
  .action(handleAdd);

Run it during development with npx tsx src/index.ts add "Docs" https://typescriptlang.org, build with npx tsc, and npm link to install the bm binary globally. Publishing to npm needs only a bin field in package.json, a #!/usr/bin/env node shebang on the entry file, and prepublishOnly: "tsc" so the compiled dist/ ships instead of raw TypeScript.

Gotchas That Catch Everyone Once

any vs unknownany silently accepts anything and disables checking; unknown accepts anything too but forces a narrowing check before use. Default to unknown.

Excess property checking only fires on object literals assigned directly to a typed variable — route the same data through an intermediate const and the check vanishes, because structural typing only cares that the required properties exist.

Optional (?) and default (=) parameters are not the same — optional adds undefined to the type and must be last; default supplies a real fallback value, its type excludes undefined, and it doesn’t have to be last.

typeof null === "object" — a 1995 JavaScript bug TypeScript inherited. Don’t rely on typeof alone to rule out null.

Truthiness narrowing treats 0 like nullif (count) skips the 0 case along with the missing case. Use an explicit !== null check for numbers that can legitimately be zero.

private is compile-time only — nothing stops JavaScript from reading a “private” field at runtime. Use #field syntax when you need real runtime privacy.

Declaration merging is interface-only — declaring the same type twice is an error; declaring the same interface twice merges the members. Useful for extending third-party types, confusing if it happens by accident.

Distributive conditional types spread over unions by defaultToArray<string | number> becomes string[] | number[], not (string | number)[]. Wrap the checked type in [T] when you want the union treated as one unit.

Path aliases need two configs, not onetsconfig.json’s paths only tells the type checker where to look; your bundler (Vite, webpack) needs a matching alias entry or the built code can’t actually resolve the import.

Where to Go From Here

The complete, working code for the capstone project (the bm bookmark manager) is on GitHub: github.com/kemalcodes/typescript-tutorial.