Table of Contents

1. The Type System -- How TS Thinks 2. Generics 3. Conditional Types 4. Mapped Types 5. Utility Types 6. keyof, typeof & Index Access 7. The infer Keyword 8. Decorators 9. Modules, Namespaces & Declaration Files 10. tsconfig.json Deep Dive 11. The TypeScript Compiler 12. Real-World Patterns & Gotchas

1. The Type System -- How TS Thinks

TypeScript uses a structural type system (duck typing), not a nominal one. Two types are compatible if their shapes match, regardless of what they're named.

TypeScript
interface Dog { name: string; bark(): void }
interface Pet { name: string }

const d: Dog = { name: "Rex", bark() { console.log("woof") } };
const p: Pet = d; // ✅ OK -- Dog has everything Pet needs
Structural vs Nominal: In C++ or Java, Dog and Pet would need an explicit extends/implements relationship. In TS, shape is all that matters.

Type Narrowing

TS narrows types through control flow analysis. After a check, the compiler knows the type is more specific.

TypeScript
function process(val: string | number) {
  if (typeof val === "string") {
    // TS knows val is string here
    console.log(val.toUpperCase());
  } else {
    // TS knows val is number here
    console.log(val.toFixed(2));
  }
}

Discriminated Unions

The most powerful pattern for modeling domain types. Add a literal "tag" field that TS uses to narrow.

TypeScript
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rect"; w: number; h: number };

function area(s: Shape): number {
  switch (s.kind) {
    case "circle": return Math.PI * s.radius ** 2;
    case "rect":   return s.w * s.h;
  }
}

2. Generics

Generics let you write code that works with any type while keeping type safety. Think of <T> as a type parameter -- a variable, but for types.

TypeScript
// Without generics -- loses type info
function identity(val: any): any { return val; }

// With generics -- T flows through
function identity<T>(val: T): T { return val; }

const num = identity(42);      // type: number (inferred)
const str = identity("hi");    // type: string (inferred)

Generic Constraints

Use extends to constrain what T can be.

TypeScript
function getLength<T extends { length: number }>(item: T): number {
  return item.length;
}

getLength("hello");   // ✅ string has .length
getLength([1,2,3]);  // ✅ array has .length
getLength(42);        // ❌ number has no .length

Multiple Type Parameters

TypeScript
function merge<A, B>(a: A, b: B): A & B {
  return { ...a, ...b };
}

const result = merge({ name: "Sean" }, { age: 25 });
// type: { name: string } & { age: number }

Generic Interfaces & Classes

TypeScript
interface Repository<T> {
  getById(id: string): Promise<T | null>;
  save(entity: T): Promise<void>;
}

class UserRepo implements Repository<User> {
  async getById(id: string) { /* ... */ }
  async save(user: User) { /* ... */ }
}

Default Type Parameters

TypeScript
interface ApiResponse<T = unknown> {
  data: T;
  status: number;
}

const res: ApiResponse = { data: "anything", status: 200 };
const typed: ApiResponse<User> = { data: user, status: 200 };

3. Conditional Types

Conditional types are if/else for types. The syntax mirrors the ternary operator.

TypeScript
// Syntax: T extends U ? TrueType : FalseType

type IsString<T> = T extends string ? "yes" : "no";

type A = IsString<"hello">;  // "yes"
type B = IsString<42>;       // "no"

Distributive Conditional Types

When T is a union, the conditional distributes over each member individually.

TypeScript
type ToArray<T> = T extends any ? T[] : never;

type R = ToArray<string | number>;
// = string[] | number[]  (NOT (string | number)[])

// To prevent distribution, wrap in tuple:
type ToArrayND<T> = [T] extends [any] ? T[] : never;
type R2 = ToArrayND<string | number>;
// = (string | number)[]

Nested Conditional Types

TypeScript
type TypeName<T> =
  T extends string  ? "string" :
  T extends number  ? "number" :
  T extends boolean ? "boolean" :
  T extends Function ? "function" :
  "object";

type T1 = TypeName<string>;   // "string"
type T2 = TypeName<() => void>; // "function"

4. Mapped Types

Mapped types transform every property of an existing type. They iterate over keys with in.

TypeScript
// Make every property optional
type MyPartial<T> = {
  [K in keyof T]?: T[K];
};

// Make every property readonly
type MyReadonly<T> = {
  readonly [K in keyof T]: T[K];
};

Key Remapping with as

TypeScript
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

interface User { name: string; age: number }
type UserGetters = Getters<User>;
// { getName: () => string; getAge: () => number }

Filtering Keys

TypeScript
// Keep only string properties
type StringProps<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};

type R = StringProps<{ name: string; age: number; email: string }>;
// { name: string; email: string }

5. Utility Types

TS ships with utility types built from generics + conditionals + mapped types. Here's how they work under the hood.

TypeScript
// Partial<T> -- make all props optional
type Partial<T> = { [K in keyof T]?: T[K] };

// Required<T> -- make all props required
type Required<T> = { [K in keyof T]-?: T[K] };

// Readonly<T> -- make all props readonly
type Readonly<T> = { readonly [K in keyof T]: T[K] };

// Pick<T, K> -- select specific keys
type Pick<T, K extends keyof T> = { [P in K]: T[P] };

// Omit<T, K> -- remove specific keys
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;

// Record<K, V> -- build an object type
type Record<K extends keyof any, V> = { [P in K]: V };

// Exclude<T, U> -- remove types from a union
type Exclude<T, U> = T extends U ? never : T;

// Extract<T, U> -- keep types from a union
type Extract<T, U> = T extends U ? T : never;

// NonNullable<T> -- remove null and undefined
type NonNullable<T> = T extends null | undefined ? never : T;

// ReturnType<T> -- extract function return type
type ReturnType<T extends (...args: any) => any> =
  T extends (...args: any) => infer R ? R : any;

// Parameters<T> -- extract function params as tuple
type Parameters<T extends (...args: any) => any> =
  T extends (...args: infer P) => any ? P : never;
Using them together:
TypeScript
interface User { id: string; name: string; email: string; role: string }

type CreateUserInput = Omit<User, "id">;
type UpdateUserInput = Partial<Pick<User, "name" | "email">>;
type UserLookup = Record<string, User>;

6. keyof, typeof & Index Access

keyof -- Get All Keys

TypeScript
interface Config { host: string; port: number; debug: boolean }

type ConfigKey = keyof Config; // "host" | "port" | "debug"

function getConfig<K extends keyof Config>(key: K): Config[K] {
  return config[key];
}

const h = getConfig("host");  // type: string
const p = getConfig("port");  // type: number

typeof -- Get Type from Value

TypeScript
const defaults = { host: "localhost", port: 3000, debug: false };

type Config = typeof defaults;
// { host: string; port: number; debug: boolean }

// Combine with keyof:
type ConfigKey = keyof typeof defaults;
// "host" | "port" | "debug"

Index Access Types

TypeScript
interface User { name: string; address: { city: string; zip: string } }

type Address = User["address"];       // { city: string; zip: string }
type City = User["address"]["city"]; // string

// Index into arrays:
const roles = ["admin", "user", "guest"] as const;
type Role = (typeof roles)[number]; // "admin" | "user" | "guest"

7. The infer Keyword

infer lets you extract types from within other types inside conditional type branches. Think of it as pattern matching for types.

TypeScript
// Extract the return type of a function
type MyReturnType<T> =
  T extends (...args: any[]) => infer R ? R : never;

type R1 = MyReturnType<() => string>;       // string
type R2 = MyReturnType<(x: number) => User>; // User

More infer Patterns

TypeScript
// Extract element type from array
type ElementOf<T> = T extends (infer E)[] ? E : never;
type E = ElementOf<string[]>; // string

// Extract Promise inner type
type Awaited<T> = T extends Promise<infer U> ? U : T;
type A = Awaited<Promise<string>>; // string

// Extract first arg of a function
type FirstArg<T> =
  T extends (first: infer F, ...rest: any[]) => any ? F : never;

type F = FirstArg<(name: string, age: number) => void>; // string

// Extract the type from a class constructor
type InstanceOf<T> =
  T extends new (...args: any[]) => infer I ? I : never;
Rule: infer can only be used inside the extends clause of a conditional type. Think of it as "capture whatever type appears in this position."

8. Decorators

Decorators are functions that modify classes, methods, or properties at definition time. They're used heavily in frameworks like NestJS, TypeORM, and Angular.

Stage 3 Decorators (TC39): TS 5.0+ supports the new standard decorator syntax. Older code uses "experimentalDecorators": true in tsconfig. The new standard does NOT require that flag.

Class Decorators

TypeScript
// A decorator is just a function that receives the target
function Sealed(target: Function) {
  Object.seal(target);
  Object.seal(target.prototype);
}

@Sealed
class UserService {
  getUser() { /* ... */ }
}

Method Decorators

TypeScript
function Log(target: any, key: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value;
  descriptor.value = function(...args: any[]) {
    console.log(`Calling ${key} with`, args);
    return original.apply(this, args);
  };
}

class MathService {
  @Log
  add(a: number, b: number) { return a + b; }
}

Decorator Factories (Decorators with Args)

TypeScript
function MinLength(min: number) {
  return function(target: any, key: string) {
    let value: string;
    Object.defineProperty(target, key, {
      get: () => value,
      set: (newVal: string) => {
        if (newVal.length < min)
          throw new Error(`${key} must be at least ${min} chars`);
        value = newVal;
      }
    });
  };
}

class User {
  @MinLength(3)
  name: string = "";
}

9. Modules, Namespaces & Declaration Files

ES Modules in TypeScript

TS follows the ESM standard. Any file with a top-level import or export is a module.

TypeScript
// math.ts
export function add(a: number, b: number): number {
  return a + b;
}

// app.ts
import { add } from "./math";   // named import
import * as math from "./math"; // namespace import

Namespaces (Legacy)

Namespaces predate ES modules. They're still used in .d.ts files and ambient declarations but should be avoided in application code.

TypeScript
namespace Validation {
  export interface Validator {
    isValid(s: string): boolean;
  }
  export class EmailValidator implements Validator {
    isValid(s: string) { return s.includes("@"); }
  }
}

const v = new Validation.EmailValidator();

Declaration Files (.d.ts)

Declaration files describe the shape of JS code so TS can type-check it. They contain only types, no runtime code.

TypeScript
// global.d.ts -- add types for things TS doesn't know about
declare module "*.css" {
  const classes: { [key: string]: string };
  export default classes;
}

declare module "*.svg" {
  const content: string;
  export default content;
}
This is why CSS imports cause TS errors! TypeScript doesn't know what a .css file exports. You need a declaration file (like above) or the *.css module declaration in a .d.ts file that your tsconfig includes. Without it, TS says "Cannot find module './styles.css'." Same applies to .svg, .png, .json (unless resolveJsonModule is on), etc.

declare module for Augmenting Existing Types

TypeScript
// Extend Express Request type to add your custom user field
declare module "express" {
  interface Request {
    user?: { id: string; role: string };
  }
}

// Now req.user is typed everywhere in your Express app

Triple-Slash Directives

TypeScript
/// <reference types="node" />
/// <reference path="./globals.d.ts" />
// These are legacy -- prefer tsconfig "types" and "include" instead

10. tsconfig.json Deep Dive

Every TS project has a tsconfig.json. Here are the options that actually matter, grouped by what they do.

Module & Resolution

JSON
{
  "compilerOptions": {
    // What module system to output
    "module": "NodeNext",         // or "ESNext", "CommonJS"

    // How TS finds modules when you import
    "moduleResolution": "NodeNext", // or "Bundler" for Vite/Webpack

    // Where compiled JS goes
    "outDir": "./dist",

    // Root of source files
    "rootDir": "./src",

    // Allow importing JSON files
    "resolveJsonModule": true,

    // Path aliases: import from "@/utils" instead of "../../utils"
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  }
}

Strictness

JSON
{
  "compilerOptions": {
    "strict": true,              // Enables ALL strict checks below:
    // "strictNullChecks": true   -- null/undefined are their own types
    // "strictFunctionTypes": true -- stricter function type checking
    // "strictBindCallApply": true -- check bind/call/apply args
    // "noImplicitAny": true      -- error on implicit 'any'
    // "noImplicitThis": true     -- error on implicit 'this'

    "noUncheckedIndexedAccess": true, // obj[key] includes undefined
    "noUnusedLocals": true,
    "noUnusedParameters": true
  }
}

Target & Lib

JSON
{
  "compilerOptions": {
    // What JS version to compile down to
    "target": "ES2022",

    // What APIs are available (DOM, ES2022, etc.)
    "lib": ["ES2022", "DOM", "DOM.Iterable"],

    // Emit .d.ts files for libraries
    "declaration": true,

    // Generate source maps for debugging
    "sourceMap": true,

    // Allow JS files in TS project
    "allowJs": true,

    // Skip type-checking node_modules .d.ts
    "skipLibCheck": true
  }
}

Interop Flags

JSON
{
  "compilerOptions": {
    // Allow `import React from "react"` even if module has no default export
    "esModuleInterop": true,

    // Allow `import x from "./file"` where file is CJS
    "allowSyntheticDefaultImports": true,

    // Ensure consistent casing in imports (catches bugs on case-insensitive OS)
    "forceConsistentCasingInFileNames": true,

    // Each file is treated as a separate module (faster builds, needed for Babel)
    "isolatedModules": true
  }
}
Common tsconfig starters:
npx tsc --init -- generates a tsconfig with all options commented out
@tsconfig/node20 -- community base config for Node 20
@tsconfig/strictest -- maximum strictness

11. The TypeScript Compiler

The TS compiler (tsc) does two separate jobs: type checking and code emission. Modern setups often split these.

Bash
# Type-check only (no output)
tsc --noEmit

# Compile and emit JS
tsc

# Watch mode
tsc --watch

# Check a specific file
tsc --noEmit src/index.ts

The Compilation Pipeline

Text
Source (.ts) → Scanner → Tokens → Parser → AST → Binder → Symbols
  → Type Checker → Diagnostics (errors)
  → Emitter → Output (.js, .d.ts, .js.map)

Why Modern Projects Don't Use tsc for Bundling

Common modern setup:
  • tsc --noEmit in CI for type checking only
  • esbuild / swc / Vite for fast compilation (strips types, no type checking)
  • tsup for library bundling (uses esbuild under the hood)

This gives you fast builds + full type safety from separate type-check step.

Project References

For monorepos, use references to compile packages in order.

JSON
// tsconfig.json (root)
{
  "references": [
    { "path": "./packages/shared" },
    { "path": "./packages/api" },
    { "path": "./packages/web" }
  ]
}

12. Real-World Patterns & Gotchas

Type Assertion vs Type Guard

TypeScript
// Assertion (you're telling TS, "trust me") -- risky
const el = document.getElementById("app") as HTMLDivElement;

// Type guard (TS verifies at runtime) -- safe
function isHTMLDiv(el: Element | null): el is HTMLDivElement {
  return el instanceof HTMLDivElement;
}

Exhaustive Checking with never

TypeScript
type Action = { type: "add" } | { type: "remove" } | { type: "update" };

function handle(action: Action) {
  switch (action.type) {
    case "add": break;
    case "remove": break;
    case "update": break;
    default: {
      // If you miss a case, this line errors at compile time
      const _exhaustive: never = action;
      throw new Error(`Unhandled: ${_exhaustive}`);
    }
  }
}

Branded / Opaque Types

TypeScript
// Prevent mixing up IDs that are all strings
type UserId = string & { __brand: "UserId" };
type PostId = string & { __brand: "PostId" };

function getUser(id: UserId) { /* ... */ }

const uid = "abc" as UserId;
const pid = "abc" as PostId;

getUser(uid); // ✅
getUser(pid); // ❌ Type 'PostId' is not assignable to 'UserId'

Template Literal Types

TypeScript
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
type ApiRoute = `/${string}`;
type Endpoint = `${HttpMethod} ${ApiRoute}`;
// "GET /users" ✅  "PATCH /users" ❌

// Powerful with mapped types:
type EventMap<T> = {
  [K in keyof T as `on${Capitalize<string & K>}`]: (val: T[K]) => void;
};

The satisfies Operator (TS 4.9+)

TypeScript
type Colors = Record<string, [number, number, number] | string>;

// 'satisfies' validates the type but keeps the NARROW inferred type
const palette = {
  red: [255, 0, 0],
  green: "#00ff00",
} satisfies Colors;

palette.red[0];     // ✅ type: number (not string | number[] -- kept narrow)
palette.green.toUpperCase(); // ✅ type: string

Common Gotcha: Object.keys Returns string[]

TypeScript
const obj = { a: 1, b: 2 };
Object.keys(obj); // string[], NOT ("a" | "b")[]

// Why? TS structural typing means obj could have MORE keys at runtime.
// Workaround when you're sure:
const keys = Object.keys(obj) as (keyof typeof obj)[];