LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

TypeScript Deep Dive

typescriptjavascripttype-systemweb-developmentbackend

TypeScript has won. What started as a Microsoft project in 2012 now powers Slack, Airbnb, Stripe, and effectively the entire Node.js backend ecosystem. Vue 3 was rewritten in TypeScript. Angular was built on it from day one. Even plain JavaScript projects routinely use TypeScript for editor intelligence via JSDoc. If you write serious JavaScript today and aren’t using TypeScript, you’re working against the grain.

This isn’t a beginner’s guide. It assumes you know JavaScript and at least one typed language. The goal is to explain the parts of TypeScript that actually matter—the type system features that take work to understand but pay back every day.


Why TypeScript Over Plain JavaScript

The concrete value is catching specific categories of bugs at compile time instead of in production:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// JavaScript: crashes at runtime when user is undefined
const user = getUser(id);
console.log(user.email);  // TypeError: Cannot read properties of undefined

// TypeScript with strictNullChecks: error at compile time
const user: User | undefined = getUser(id);
console.log(user.email);  // TS2532: Object is possibly 'undefined'

// You're forced to handle it
if (user) {
  console.log(user.email);  // ✓
}

Beyond null safety, the case for TypeScript is practical:

Refactoring confidence. Rename a property and TypeScript highlights every call site. In a 50k-line JavaScript codebase, you’d be searching and hoping.

Self-documenting APIs. A function signature createUser(name: string, role: "admin" | "viewer"): Promise<User> tells you everything. No digging through the implementation or reading stale documentation.

IDE leverage. Autocomplete, jump-to-definition, and inline error highlighting all work because of TypeScript’s type information. The editor becomes a pair programmer.

Reduced unit testing. A class of tests you’d write in JavaScript—“does this function handle undefined input?"—become unnecessary when TypeScript enforces the contract.

Honest trade-offs:

  • Build step required (fast with esbuild/swc, but it’s still there)
  • Initial development slower (writing types takes time)
  • Verbose for small scripts (overkill for a 30-line file)
  • any is always an escape hatch—teams need discipline

Type System Fundamentals

Primitives and Basics

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// Primitives inferred or explicit
let count = 10;               // inferred: number
let name: string = "web01";
let enabled: boolean = true;

// Arrays
const nums: number[] = [1, 2, 3];
const mixed: (string | number)[] = [1, "two", 3];

// Tuple — fixed-length array with typed positions
const entry: [string, number, boolean] = ["web01", 22, true];
const [host, port] = entry;  // destructuring preserves types

// Readonly variants
const config: readonly string[] = ["a", "b"];
config.push("c");  // TS error — readonly

Object Types: type vs interface

Both type and interface define the shape of an object. The practical differences:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// interface: extensible, supports declaration merging
interface User {
  name: string;
  email: string;
  age?: number;       // optional property
  readonly id: string;  // immutable after creation
}

interface AdminUser extends User {
  permissions: string[];
}

// type alias: supports unions, intersections, can't be merged
type Status = "idle" | "loading" | "success" | "error";
type Result<T> = { success: true; data: T } | { success: false; error: string };

Convention: use interface for object shapes that might be extended; use type for unions, intersections, and aliases.

Union and Intersection Types

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// Union: one of these types
type ID = string | number;
type Status = "pending" | "active" | "disabled";

function formatID(id: ID): string {
  return typeof id === "number" ? String(id) : id;
}

// Intersection: all of these types combined
interface Timestamped {
  createdAt: Date;
  updatedAt: Date;
}

type UserRecord = User & Timestamped;
// Has all User properties AND all Timestamped properties

Structural Typing

TypeScript uses structural typing—compatibility is based on shape, not name. This is fundamentally different from Java or C#:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
interface Point2D {
  x: number;
  y: number;
}

// No 'implements Point2D' needed
class Vector {
  constructor(public x: number, public y: number) {}
}

const p: Point2D = new Vector(1, 2);  // ✓ — structurally compatible

// Any object with x and y satisfies Point2D
function distance(a: Point2D, b: Point2D): number {
  return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2);
}

distance({ x: 0, y: 0 }, { x: 3, y: 4 });  // ✓ — plain object works too

The downside: accidentally compatible types. Two types with the same structure are interchangeable even if semantically different. Branded types (covered below) solve this.


Generics

Generics let you write code that works across types while preserving type information.

Generic Functions

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// Without generics: loses type information
function first(arr: any[]): any {
  return arr[0];
}

const x = first([1, 2, 3]);  // x is any — loses number

// With generics: T is inferred from the argument
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

const x = first([1, 2, 3]);         // x is number
const y = first(["a", "b"]);        // y is string
const z = first<boolean>([true]);   // explicit — z is boolean

Generic Constraints

Use extends to constrain what types a generic can accept:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// T must have a length property
function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length >= b.length ? a : b;
}

longest("hello", "hi");       // ✓ string has .length
longest([1, 2], [1]);         // ✓ array has .length
longest(1, 2);                // ✗ number has no .length

// keyof constraint — K must be a key of T
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { name: "Alice", age: 30 };
getProperty(user, "name");    // ✓ returns string
getProperty(user, "email");   // ✗ 'email' not in keyof User

Conditional Types

Types that evaluate based on a condition:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// T extends SomeType ? TrueType : FalseType
type IsArray<T> = T extends any[] ? true : false;

type A = IsArray<string[]>;  // true
type B = IsArray<number>;    // false

// The infer keyword: extract a type from within another type
type UnwrapArray<T> = T extends Array<infer U> ? U : T;

type Str = UnwrapArray<string[]>;   // string
type Num = UnwrapArray<number>;     // number

// Extract resolved type from a Promise
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T;

type R = Awaited<Promise<Promise<string>>>;  // string

Mapped Types

Transform every property in a type:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// Make all properties optional
type Partial<T> = {
  [K in keyof T]?: T[K];
};

// Make all properties readonly
type Readonly<T> = {
  readonly [K in keyof T]: T[K];
};

// Rename properties (mapped type with key remapping)
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

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

Template Literal Types

Create types from string patterns:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
type HTTPMethod = "GET" | "POST" | "PUT" | "DELETE";
type APIPath = "/users" | "/posts" | "/comments";

type Route = `${HTTPMethod} ${APIPath}`;
// "GET /users" | "GET /posts" | "GET /comments" |
// "POST /users" | "POST /posts" | "POST /comments" | ...

// Practical: event system with typed handlers
type EventHandlerMap<T extends Record<string, any>> = {
  [K in keyof T as `on${Capitalize<string & K>}`]: (data: T[K]) => void;
};

type Events = {
  message: { text: string };
  error: { code: number; message: string };
};

type Handlers = EventHandlerMap<Events>;
// { onMessage: (data: { text: string }) => void;
//   onError: (data: { code: number; message: string }) => void }

Utility Types

TypeScript ships built-in utility types for common type transformations:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
interface User {
  id: string;
  name: string;
  email: string;
  role: "admin" | "viewer";
}

// Partial<T>: all properties optional — useful for update/patch operations
type UserUpdate = Partial<User>;
function updateUser(id: string, changes: Partial<User>) { }

// Required<T>: all properties required (reverse of Partial)
type StrictUser = Required<Partial<User>>;

// Readonly<T>: prevent mutations — useful for Redux state
type ImmutableUser = Readonly<User>;

// Pick<T, K>: include only specific properties
type UserPreview = Pick<User, "id" | "name">;
// { id: string; name: string }

// Omit<T, K>: exclude specific properties
type PublicUser = Omit<User, "email">;
// { id: string; name: string; role: "admin" | "viewer" }

// Record<K, V>: object with specific key type and value type
type StatusMessages = Record<"pending" | "success" | "error", string>;
const messages: StatusMessages = {
  pending: "Loading...",
  success: "Done!",
  error: "Something went wrong"
};

// ReturnType<F>: extract the return type of a function
async function fetchUser(id: string): Promise<User> { /* ... */ }
type FetchResult = Awaited<ReturnType<typeof fetchUser>>;  // User

// Parameters<F>: extract the parameter types as a tuple
type FetchParams = Parameters<typeof fetchUser>;  // [string]

// Extract and Exclude for union manipulation
type NumericStatus = Extract<Status, number>;      // number members only
type NonNullable<T> = Exclude<T, null | undefined>; // remove null/undefined

Type Narrowing

TypeScript narrows the type of a value based on runtime checks. The compiler tracks what’s possible after each check.

typeof and instanceof

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
function format(value: string | number | Date): string {
  if (typeof value === "string") {
    return value.toUpperCase();    // string here
  } else if (typeof value === "number") {
    return value.toFixed(2);       // number here
  } else {
    return value.toISOString();    // Date here
  }
}

function handleError(err: Error | string) {
  if (err instanceof Error) {
    console.error(err.message);   // Error here
  } else {
    console.error(err);           // string here
  }
}

Discriminated Unions

The most powerful narrowing pattern. A shared literal property acts as the discriminant:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
type ApiResult<T> =
  | { status: "success"; data: T }
  | { status: "error"; error: string; code: number }
  | { status: "loading" };

function handleResult<T>(result: ApiResult<T>) {
  switch (result.status) {
    case "success":
      console.log(result.data);    // T
      break;
    case "error":
      console.log(result.error, result.code);  // string, number
      break;
    case "loading":
      // no extra properties here
      break;
  }
}

Use discriminated unions instead of nested if chains whenever you have a fixed set of states. They’re exhaustively checked by the compiler.

User-Defined Type Guards

When built-in narrowing isn’t enough, write a type predicate:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
function isUser(obj: unknown): obj is User {
  return (
    typeof obj === "object" &&
    obj !== null &&
    typeof (obj as any).name === "string" &&
    typeof (obj as any).email === "string"
  );
}

const data: unknown = await fetchData();
if (isUser(data)) {
  console.log(data.email);  // TS knows it's User
}

Pair type guards with runtime validation libraries—see the Zod section below.

The never Type as Exhaustiveness Checker

never is the type with no values. Use it to ensure switch statements handle all cases:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
type Shape = "circle" | "square" | "triangle";

function area(shape: Shape, size: number): number {
  switch (shape) {
    case "circle":  return Math.PI * size ** 2;
    case "square":  return size ** 2;
    case "triangle": return (Math.sqrt(3) / 4) * size ** 2;
    default:
      const _exhaustive: never = shape;
      throw new Error(`Unhandled shape: ${_exhaustive}`);
  }
}

// Add "hexagon" to Shape — TS error in the default branch
// Forces you to handle the new case

Advanced Patterns

const Assertions

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// Without as const: widened types
const config = { env: "production", port: 3000 };
// config.env is string, config.port is number

// With as const: literal types, readonly
const config = { env: "production", port: 3000 } as const;
// config.env is "production", config.port is 3000
// config is deeply readonly

// Most useful for tuples
const RGB = [255, 128, 0] as const;
type Color = typeof RGB;  // readonly [255, 128, 0]

// And string unions from arrays
const METHODS = ["GET", "POST", "PUT", "DELETE"] as const;
type Method = typeof METHODS[number];  // "GET" | "POST" | "PUT" | "DELETE"

The satisfies Operator (TypeScript 4.9+)

Check that a value matches a type while preserving the literal types for downstream inference:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
type Colors = Record<string, [number, number, number] | string>;

// Without satisfies: TS widens to Colors, loses specific types
const palette: Colors = {
  red: [255, 0, 0],
  green: "#00ff00",
};
palette.red.at(0);     // ✗ — red is [number, number, number] | string

// With satisfies: validates against Colors AND keeps specific types
const palette = {
  red: [255, 0, 0],
  green: "#00ff00",
} satisfies Colors;

palette.red.at(0);     // ✓ — red is [number, number, number]
palette.green.toUpperCase();  // ✓ — green is string

Branded Types (Nominal Typing)

TypeScript is structurally typed—two types with the same shape are interchangeable. Brands prevent this when semantic correctness matters:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Without brands: any string is a valid UserId
function deleteUser(id: string) { }
function deletePost(id: string) { }

deleteUser(postId);   // ✓ compiles — but semantically wrong

// With brands: structurally distinct even though both are strings
type UserId = string & { readonly __brand: "UserId" };
type PostId = string & { readonly __brand: "PostId" };

function makeUserId(id: string): UserId {
  return id as UserId;
}

function deleteUser(id: UserId) { }
function deletePost(id: PostId) { }

const uid = makeUserId("user-123");
const pid = "post-456" as PostId;

deleteUser(uid);    // ✓
deleteUser(pid);    // ✗ PostId is not assignable to UserId
deleteUser("raw"); // ✗ string is not assignable to UserId

Recursive Types

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// A tree node
type TreeNode<T> = {
  value: T;
  children: TreeNode<T>[];
};

// Type-safe JSON
type JsonPrimitive = string | number | boolean | null;
type JsonObject = { [key: string]: JsonValue };
type JsonArray = JsonValue[];
type JsonValue = JsonPrimitive | JsonObject | JsonArray;

// Deeply nested partial (built-in DeepPartial not in stdlib, but easy to write)
type DeepPartial<T> = T extends object
  ? { [K in keyof T]?: DeepPartial<T[K]> }
  : T;

Error Handling with Discriminated Unions

TypeScript makes functional-style error handling elegant:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

function tryParse(json: string): Result<unknown> {
  try {
    return { ok: true, value: JSON.parse(json) };
  } catch (e) {
    return { ok: false, error: e instanceof Error ? e : new Error(String(e)) };
  }
}

const result = tryParse('{"name": "Alice"}');
if (result.ok) {
  console.log(result.value);  // unknown — needs further narrowing
} else {
  console.error(result.error.message);
}

This pattern avoids try/catch scattered through code and makes error handling visible in function signatures.


Decorators (TypeScript 5.x)

TypeScript 5.x ships the TC39 standard decorators. They’re different from the legacy experimentalDecorators option—if you’re starting fresh, use the new style:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// Class decorator — runs once when class is defined
function singleton<T extends { new(...args: any[]): {} }>(Base: T) {
  let instance: InstanceType<T>;
  return class extends Base {
    constructor(...args: any[]) {
      if (instance) return instance;
      super(...args);
      instance = this as InstanceType<T>;
    }
  };
}

@singleton
class DatabaseConnection {
  connect() { /* ... */ }
}

// Method decorator — wraps a method with additional behavior
function log(target: any, context: ClassMethodDecoratorContext) {
  const name = String(context.name);
  return function(this: any, ...args: any[]) {
    console.log(`Calling ${name}(${args.join(", ")})`);
    const result = target.call(this, ...args);
    console.log(`${name} returned ${result}`);
    return result;
  };
}

class Calculator {
  @log
  add(a: number, b: number): number {
    return a + b;
  }
}

// Property decorator — add metadata or validation
function positive(target: undefined, context: ClassFieldDecoratorContext) {
  return function(this: any, value: number) {
    if (value <= 0) throw new Error(`${String(context.name)} must be positive`);
    return value;
  };
}

class Config {
  @positive
  timeout: number = 30;
}

Real-world decorators are most common in frameworks: NestJS uses them for controllers, routes, and dependency injection; Angular uses them for components and services; class-validator uses them for validation rules.


tsconfig.json: The Options That Matter

Always start with "strict": true. It enables a bundle of checks that prevent entire categories of bugs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
  "compilerOptions": {
    "strict": true,
    // strict enables:
    // "strictNullChecks": true        — null/undefined must be handled explicitly
    // "noImplicitAny": true           — no implicit any types
    // "strictFunctionTypes": true     — stricter function assignability
    // "strictPropertyInitialization": true — class properties must be initialized
    // "useUnknownInCatchVariables": true   — catch clause variables are unknown, not any
  }
}

Module Resolution

This is the most confusing part of tsconfig for Node.js projects:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
{
  "compilerOptions": {
    // For Node.js with native ESM
    "module": "Node16",
    "moduleResolution": "Node16",

    // For bundler-based projects (Vite, webpack, esbuild)
    "module": "ESNext",
    "moduleResolution": "bundler",

    // Legacy (avoid for new projects)
    "module": "CommonJS",
    "moduleResolution": "node"
  }
}

Use moduleResolution: "bundler" if you’re using Vite, Next.js, or esbuild. Use Node16 or NodeNext for bare Node.js projects with ESM. The distinction matters because bundlers and Node.js resolve imports differently.

Path Aliases

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"],
      "@components/*": ["./src/components/*"],
      "@utils/*": ["./src/utils/*"]
    }
  }
}

Note: tsconfig paths affect type checking only, not runtime resolution. You also need to configure your bundler (esbuild, webpack, vite) to resolve the same aliases at runtime.

Practical tsconfig for a Node.js Backend

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

noUncheckedIndexedAccess is not enabled by strict but is worth adding — it makes array indexing return T | undefined instead of T, preventing off-by-one crashes.


Runtime Validation with Zod

TypeScript’s types disappear at runtime. Anything coming from outside your codebase — HTTP requests, file reads, environment variables — is untyped at runtime. Use a validation library to bridge the gap:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import { z } from "zod";

const UserSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  age: z.number().int().positive().optional(),
  role: z.enum(["admin", "viewer"]),
});

// Infer the TypeScript type from the schema — single source of truth
type User = z.infer<typeof UserSchema>;

// Validate at the API boundary
app.post("/users", (req, res) => {
  const result = UserSchema.safeParse(req.body);

  if (!result.success) {
    return res.status(400).json({
      error: "Invalid request",
      details: result.error.flatten()
    });
  }

  const user: User = result.data;  // Guaranteed valid here
  createUser(user);
});

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

const env = EnvSchema.parse(process.env);  // Throws on missing/invalid

The z.infer<typeof Schema> pattern means your runtime schema and TypeScript types stay in sync automatically.


Ecosystem

Transpilation

Tool Speed Use Case
tsc Slow Type checking + emit in one step; CI
esbuild Very fast Bundling; strips types, no type checking
swc Very fast Drop-in tsc replacement for transpile-only
oxc Fastest New Rust toolchain; growing ecosystem

For development: use tsx (wraps esbuild) to run TypeScript files directly. For production builds: use esbuild or swc to transpile, then run tsc --noEmit separately for type checking in CI.

1
2
3
4
5
6
7
8
# Development
npx tsx src/server.ts

# Type check only (no emit)
npx tsc --noEmit

# Build
npx esbuild src/server.ts --bundle --platform=node --outfile=dist/server.js

Testing

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// Vitest — fast, TypeScript-native
import { describe, it, expect, vi } from "vitest";

describe("UserService", () => {
  it("creates a user", async () => {
    const mockDb = {
      insert: vi.fn().mockResolvedValue({ id: "123", name: "Alice" }),
    };

    const service = new UserService(mockDb as any);
    const user = await service.create({ name: "Alice", email: "alice@example.com" });

    expect(user.id).toBe("123");
    expect(mockDb.insert).toHaveBeenCalledOnce();
  });
});

Common Mistakes

Using any When You Mean unknown

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// ✗ any — disables all type checking downstream
function processResponse(data: any) {
  return data.items.map((item: any) => item.id);  // No safety
}

// ✓ unknown — forces explicit narrowing before use
function processResponse(data: unknown) {
  if (!Array.isArray(data)) throw new Error("Expected array");
  return data.map(item => {
    if (typeof item !== "object" || !item || !("id" in item)) {
      throw new Error("Invalid item");
    }
    return item.id;
  });
}

// ✓ Better — define a schema
const ResponseSchema = z.object({ items: z.array(z.object({ id: z.string() })) });
function processResponse(data: unknown) {
  return ResponseSchema.parse(data).items.map(item => item.id);
}

Type Assertions Over Type Guards

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// ✗ Assertion bypasses checking — lies to the compiler
const user = data as User;
user.email.toUpperCase();  // Crashes if data isn't actually a User

// ✓ Type guard — checks at runtime, narrows at compile time
if (isUser(data)) {
  data.email.toUpperCase();  // Safe
}

// ✓ Zod — validates and narrows in one operation
const user = UserSchema.parse(data);  // Throws if invalid, returns User

Disabling Strict Mode

If you’re starting a new project, never turn strict off to silence errors. Those errors exist for a reason. Instead:

1
2
3
4
5
6
7
8
// Don't: "strict": false

// Do: understand why the error exists and fix it
// Often the fix is just adding | null or handling the undefined case

function getEmail(user: User | null): string {
  return user?.email ?? "no email";  // Optional chaining + nullish coalescing
}

Type-Unsafe Index Access

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
const users: User[] = getUsers();

// ✗ users[0] is User, but what if the array is empty?
const first = users[0];
console.log(first.email);  // Runtime crash if empty

// ✓ With noUncheckedIndexedAccess in tsconfig:
const first = users[0];  // User | undefined
if (first) {
  console.log(first.email);  // Safe
}

// ✓ Explicit
const [first] = users;  // User | undefined with noUncheckedIndexedAccess

A Complete Pattern: Type-Safe API Client

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// Define your API contract as types
interface Endpoint<Params, Response> {
  path: string;
  method: "GET" | "POST" | "PUT" | "DELETE";
  _params?: Params;  // phantom type — never actually used at runtime
  _response?: Response;
}

type GetUser = Endpoint<{ id: string }, User>;
type CreateUser = Endpoint<Omit<User, "id">, User>;
type ListUsers = Endpoint<{ limit?: number }, User[]>;

// Type-safe client factory
function createEndpoint<P, R>(config: Omit<Endpoint<P, R>, "_params" | "_response">): Endpoint<P, R> {
  return config as Endpoint<P, R>;
}

const endpoints = {
  getUser: createEndpoint<{ id: string }, User>({ path: "/users/:id", method: "GET" }),
  createUser: createEndpoint<Omit<User, "id">, User>({ path: "/users", method: "POST" }),
  listUsers: createEndpoint<{ limit?: number }, User[]>({ path: "/users", method: "GET" }),
};

async function request<P, R>(
  endpoint: Endpoint<P, R>,
  params: P
): Promise<R> {
  const resp = await fetch(buildUrl(endpoint.path, params), {
    method: endpoint.method,
    body: endpoint.method !== "GET" ? JSON.stringify(params) : undefined,
  });
  if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
  return resp.json();
}

// Usage — fully typed
const user = await request(endpoints.getUser, { id: "123" });
// user is User

const newUser = await request(endpoints.createUser, { name: "Alice", email: "alice@example.com", role: "viewer" });
// newUser is User

const users = await request(endpoints.listUsers, { limit: 10 });
// users is User[]

Quick Reference

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// Type aliases vs interfaces
type Union = A | B;                      // Only with type
interface Extendable extends Base { }    // Preferred for object shapes

// Generics
function wrap<T>(value: T): { value: T } { return { value }; }

// Utility types
Partial<T>          // All optional
Required<T>         // All required
Readonly<T>         // All readonly
Pick<T, "a"|"b">    // Only these keys
Omit<T, "a"|"b">    // Exclude these keys
Record<K, V>        // Object with key/value types
ReturnType<typeof f>  // Function return type
Parameters<typeof f>  // Function parameter types

// Narrowing
typeof x === "string"          // Primitive check
x instanceof Date              // Class check
"prop" in x                    // Property check
x.kind === "success"           // Discriminant check
function isUser(x): x is User  // Type predicate

// Assertions and utilities
value as string          // Type assertion (use sparingly)
value!                   // Non-null assertion (use very sparingly)
value satisfies Type     // Check type while keeping literal types
[1,2,3] as const        // Immutable literal types

// never for exhaustiveness
const _: never = value;  // Compiler error if value has remaining types

TypeScript rewards investment. The first week feels slower. After a month, you stop writing certain classes of bugs entirely, refactoring becomes mechanical instead of frightening, and the type system starts suggesting better API designs. The ceiling is high—but the payoff starts quickly once you get the core patterns down.

Comments