TypeScript

TypeScript adds a static type layer on top of JavaScript. The compiler erases all type annotations at build time — the output is plain JavaScript. The types exist purely to catch mistakes before you run the code.


🟢 Junior

Basic Types and Annotations

let name: string = 'Alice';
let age: number = 30;
let active: boolean = true;
let id: string | number = 'abc'; // union type

let scores: number[] = [100, 95, 88];
let pair: [string, number] = ['Alice', 30]; // tuple — fixed length and types

Type inference means you usually don’t need to annotate every variable. TypeScript figures it out from the initial value.

let count = 0;       // inferred as number
const greeting = 'hi'; // inferred as literal type "hi"

Interfaces and Type Aliases

Both describe object shapes. The main difference: interface can be extended with extends and merged via declaration merging; type aliases support union and intersection operators.

interface User {
  id: number;
  name: string;
  email?: string; // optional
}

type Point = { x: number; y: number };
type Shape = 'circle' | 'square' | 'triangle'; // literal union

For object shapes, prefer interface when you may need to extend it. Use type for unions, intersections, and computed shapes.

Functions

function add(a: number, b: number): number {
  return a + b;
}

const greet = (name: string): string => `Hello, ${name}!`;

function log(message: string, level: 'info' | 'warn' | 'error' = 'info'): void {
  console.log(`[${level}] ${message}`);
}

void means the function returns nothing meaningful. never means the function never returns (throws or infinite loops).

Enums vs Literal Unions

Enums compile to real JavaScript objects, which adds runtime overhead and makes tree-shaking harder. Literal unions are erased entirely at compile time.

type Direction = 'north' | 'south' | 'east' | 'west'; // preferred

enum DirectionEnum { North = 'NORTH', South = 'SOUTH' } // compiles to JS object

Prefer const objects with as const when you need the runtime values:

const Direction = { North: 'north', South: 'south' } as const;
type Direction = typeof Direction[keyof typeof Direction]; // 'north' | 'south'

🟡 Medior

Generics

Generics make functions and types reusable across different types while maintaining type safety.

function identity<T>(value: T): T {
  return value;
}

identity<string>('hello'); // T = string
identity(42);              // T = number, inferred

function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

Generic constraints restrict what T can be:

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

const user = { id: 1, name: 'Alice' };
getProperty(user, 'name'); // string — type-safe
// getProperty(user, 'age'); // Error: 'age' not in User

Utility Types

TypeScript ships with built-in generic types that transform other types.

Partial<T> makes all properties optional. Required<T> makes all properties required. Readonly<T> makes all properties read-only. Pick<T, K> keeps only the listed keys. Omit<T, K> removes the listed keys. Record<K, V> builds an object type with keys K and values V. ReturnType<F> extracts the return type of a function. Parameters<F> extracts the parameter types as a tuple.

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

type PartialUser  = Partial<User>;   // { id?: number; name?: string; email?: string }
type PublicUser   = Omit<User, 'email'>; // { id: number; name: string }
type UserUpdate   = Partial<Pick<User, 'name' | 'email'>>;

type PageMap = Record<string, string>; // { [key: string]: string }

Discriminated Unions

A discriminated union uses a literal “tag” field that is unique per variant. TypeScript narrows the type automatically inside if/switch blocks.

type Success<T> = { status: 'success'; data: T };
type Failure     = { status: 'failure'; error: string };
type Result<T>   = Success<T> | Failure;

function handleResult<T>(result: Result<T>): T | null {
  switch (result.status) {
    case 'success': return result.data;  // narrowed to Success<T>
    case 'failure':
      console.error(result.error);       // narrowed to Failure
      return null;
  }
}

Mapped Types

A mapped type creates a new type by transforming each key of an existing type.

type Optional<T> = { [K in keyof T]?: T[K] };   // same as Partial<T>
type Stringify<T> = { [K in keyof T]: string };  // all values become string
type ReadonlyDeep<T> = { readonly [K in keyof T]: T[K] };

With as you can remap the keys:

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

type UserGetters = Getters<{ name: string; id: number }>;
// { getName: () => string; getId: () => number }

Conditional Types

Conditional types select a type based on a condition, similar to a ternary operator but for types.

type IsArray<T> = T extends any[] ? true : false;

IsArray<string[]>; // true
IsArray<number>;   // false

infer extracts a type from within a conditional:

type UnpackArray<T> = T extends (infer Item)[] ? Item : T;

UnpackArray<string[]>;  // string
UnpackArray<number>;    // number (not array, returns T itself)

type Awaited<T> = T extends Promise<infer V> ? V : T;
Awaited<Promise<string>>; // string

🔴 Senior

Declaration Merging

When two declarations share the same name, TypeScript merges them. This is how library authors augment existing types.

interface Window {
  __APP_CONFIG__: Record<string, string>;
}

// Now TypeScript knows window.__APP_CONFIG__ exists
window.__APP_CONFIG__.apiUrl; // no error

Module augmentation extends types from external libraries:

import 'express';

declare module 'express' {
  interface Request {
    user?: { id: number; role: string };
  }
}

Template Literal Types

Template literal types compose string types at the type level.

type EventName = 'click' | 'focus' | 'blur';
type Handler   = `on${Capitalize<EventName>}`;
// 'onClick' | 'onFocus' | 'onBlur'

type CSSProperty = `--${string}`;
const cssVar: CSSProperty = '--primary-color'; // valid

Used heavily in ORMs (Prisma’s orderBy) and API clients to produce precise string types.

satisfies Operator

satisfies validates that a value matches a type without widening the inferred type.

type Config = Record<string, string | number>;

const config = {
  host: 'localhost',
  port: 5432,
} satisfies Config;

// config.host is string (not string | number)
// because satisfies doesn't widen — it just validates
config.host.toUpperCase(); // OK — TypeScript knows it's string

Without satisfies, declaring const config: Config = {...} would widen host to string | number.

tsconfig.json — Key Options

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "paths": { "@/*": ["./src/*"] }
  }
}

strict: true enables: noImplicitAny, strictNullChecks, strictFunctionTypes, strictPropertyInitialization, strictBindCallApply. Start every new project with this.

noUncheckedIndexedAccess makes arr[i] return T | undefined instead of T, forcing you to handle the case where the index is out of bounds.

Type-Safe API Layer with zod

Validate external data at the boundary and infer TypeScript types from the same schema:

import { z } from 'zod';

const UserSchema = z.object({
  id: z.number(),
  name: z.string().min(1),
  email: z.string().email(),
  role: z.enum(['admin', 'user', 'guest']),
});

type User = z.infer<typeof UserSchema>;

async function getUser(id: number): Promise<User> {
  const raw = await fetch(`/api/users/${id}`).then(r => r.json());
  return UserSchema.parse(raw); // throws if invalid
}

The schema is the single source of truth — no duplication between runtime validation and static types.

Senior Gotchas

as is a lie — it bypasses the type checker. Avoid it except at verified boundaries (JSON.parse results, DOM queries). Every as unknown as T is a bug waiting to happen.

any infects everything it touches. unknown is the safe alternative — it forces you to narrow before using the value.

Object.keys(obj) returns string[], not (keyof typeof obj)[]. TypeScript does this intentionally because objects can have more keys at runtime than their static type declares. Cast carefully: (Object.keys(obj) as (keyof typeof obj)[]).

Function overloads in TypeScript only validate the overload signatures — the implementation signature is not checked by callers. Always check all parameter combinations in the implementation body.

Circular references in types (recursive types) require interface or an interface-backed type alias — plain inline recursive type aliases sometimes confuse older versions of tsc.