TypeScript

All 32 notes on one page

Fundamentals

1

What is TypeScript & Why use it

beginner typescript basics tooling

TypeScript is a superset of JavaScript that adds static types. In simple language, it’s plain JS plus a type-checker that runs at build time. When we compile, TypeScript strips all the types away and gives us regular JavaScript that runs in any browser or Node.js.

So why bother? JS is dynamically typed — variables can hold anything, and we only find out we passed a string where a number was expected at runtime (usually in production at 2 AM). TS catches those mistakes while we type the code.

The three big wins:

  • Catch bugs early. Typos, wrong argument types, missing properties — flagged before we run anything.
  • Editor superpowers. Autocomplete actually knows what user. can be. Refactors (rename a field) update every usage.
  • Self-documenting code. Types describe intent — we don’t need to read the function body to know what it returns.

Think of it like wearing a seatbelt. It doesn’t make us a better driver, but it prevents the silly accidents from being catastrophic.

// Plain JS — this compiles fine and crashes at runtime
function greet(name) {
  return "Hello, " + name.toUpperCase();
}
greet(42); // 💥 name.toUpperCase is not a function

// TypeScript — caught at compile time
function greetTs(name: string): string {
  return "Hello, " + name.toUpperCase();
}
greetTs(42); // ❌ Argument of type 'number' is not assignable to 'string'

The only difference is the : string annotations. That tiny addition gives us the safety.

How it actually runs

TS code never runs directly. The TypeScript compiler (tsc) checks types, then emits JS. Tools like ts-node, tsx, Vite, and Next.js wrap this so we don’t think about it.

npm install -D typescript
npx tsc --init   # creates tsconfig.json
npx tsc          # compiles .ts → .js

When NOT to use it

For tiny throwaway scripts or a 10-line demo, TS is overkill. But anything with a team, anything that ships to users, anything we’ll touch again in 6 months — types pay for themselves quickly.

Interview soundbite

“TypeScript is a static type-checker layered on JavaScript. Types exist at compile time only — the runtime is just JS. We get earlier bug detection and much better tooling, at the cost of a build step and a learning curve.”


2

Basic Types

beginner types primitives any unknown never

TypeScript ships with a small set of primitive types that cover almost everything we write. Let’s walk through them.

The familiar three

string, number, boolean — exactly what they sound like. No separate int or float in TS; everything is number (just like JS).

const name: string = "Manish";
const age: number = 27;       // ints and floats both
const isAdmin: boolean = true;

null and undefined

Two ways JS says “nothing here”. In simple language, undefined means “not assigned yet” and null means “explicitly empty”. With strictNullChecks on (which we always want), these are NOT assignable to other types.

let maybe: string | null = null;
maybe = "hello"; // fine
let nope: string = null; // ❌ error with strict mode

any — the escape hatch

any turns off type-checking for that value. It’s like writing plain JS. Useful while migrating a codebase, dangerous everywhere else.

let yolo: any = 5;
yolo.foo.bar.baz(); // no error — and a runtime explosion waiting to happen

Rule of thumb: if we’re reaching for any, we’re probably looking for unknown.

unknown — the safe any

unknown says “I don’t know what this is, and TS will force me to check before using it”. It’s the type-safe version of any.

function parse(input: unknown) {
  // input.toUpperCase(); // ❌ can't use it directly
  if (typeof input === "string") {
    return input.toUpperCase(); // ✅ narrowed to string
  }
}

void — function returns nothing

Used for functions that don’t return a value. Different from undefinedvoid says “the return value should be ignored”.

function logIt(msg: string): void {
  console.log(msg);
  // no return statement
}

never — this can never happen

never is the type of things that never produce a value. Functions that always throw, or infinite loops. It’s also what we get when a union is fully narrowed away.

function fail(msg: string): never {
  throw new Error(msg); // never returns
}

never shows up a lot in exhaustive switch checks — see the Discriminated Unions note.

Quick reference

const a: string = "x";
const b: number = 1;
const c: boolean = true;
const d: null = null;
const e: undefined = undefined;
const f: any = "anything";      // avoid
const g: unknown = "anything";  // prefer over any
function h(): void {}
function i(): never { throw new Error(); }

Interview soundbite

any skips the check, unknown forces a check. void is for functions returning nothing, never is for functions that can’t return at all (they throw or loop forever).”


3

Type Annotations vs Type Inference

beginner annotations inference best-practices

There are two ways TypeScript learns about types: we tell it (annotations) or it figures it out from the code (inference). Knowing when to use each keeps our code clean.

Annotations — we declare the type

We add : Type after a variable, parameter, or return.

const name: string = "Manish";
function add(a: number, b: number): number {
  return a + b;
}

Inference — TS reads our code

If we initialize a variable, TS already knows its type. We don’t need to repeat ourselves.

const name = "Manish";  // inferred as string
const age = 27;         // inferred as number
const user = { id: 1, email: "x@y.com" }; // inferred as { id: number; email: string }

In simple language, if it’s obvious from the value, just don’t annotate.

The rule of thumb

  • Annotate at the boundaries — function parameters, function return types (for public APIs), and exported values.
  • Let inference handle the inside — local variables, intermediate expressions.
// ✅ Good — annotate inputs/outputs, infer locals
function calculateTotal(items: number[]): number {
  const sum = items.reduce((acc, n) => acc + n, 0); // 'sum' inferred as number
  const tax = sum * 0.18;                            // inferred
  return sum + tax;
}

// ❌ Noisy — over-annotated
function calculateTotalBad(items: number[]): number {
  const sum: number = items.reduce((acc: number, n: number) => acc + n, 0);
  const tax: number = sum * 0.18;
  return sum + tax;
}

Why annotate function returns?

Even when TS can infer the return type, writing it down has two benefits:

  1. Catches bugs at the source. If we accidentally return the wrong thing, the error shows up in the function, not at every call site.
  2. It’s a contract. Other devs see the signature without reading the body.
function getUser(id: number): { id: number; name: string } {
  // if we accidentally return null here, error is HERE not at the caller
  return { id, name: "Manish" };
}

The let vs const widening gotcha

const of a literal infers the literal type. let widens to the primitive.

const direction = "left";  // type is "left" (literal)
let dir = "left";          // type is string (widened)

This matters when assigning to typed slots — see the Literal Types note.

Inference for arrays and objects

Empty arrays are inferred as any[] — usually a mistake. Annotate when starting empty.

const nums = [];          // any[] — bad
const nums2: number[] = []; // number[] — good

Interview soundbite

“Let inference do the work. Annotate at the boundaries — function params, return types, public APIs. Avoid over-annotating locals; it’s just noise that has to be maintained.”


4

Arrays, Tuples & Readonly Arrays

beginner arrays tuples readonly immutability

Lists in TypeScript come in three flavors. Most code uses regular arrays, but tuples and readonly arrays solve real problems too.

Arrays — variable length, one type

Two equivalent syntaxes. Use whichever you prefer (T[] is more common).

const nums: number[] = [1, 2, 3];
const names: Array<string> = ["a", "b"];

nums.push(4);   // ✅
nums.push("5"); // ❌ string not assignable to number

Tuples — fixed length, fixed types per position

A tuple is an array where each index has its own type and the length is known. Think of it like an unnamed record.

type RGB = [number, number, number];
const red: RGB = [255, 0, 0];

const pair: [string, number] = ["age", 27];
// pair[0] is string, pair[1] is number

The classic use case: returning multiple values from a function (like React’s useState).

function useCounter(initial: number): [number, () => void] {
  let count = initial;
  const inc = () => { count++; };
  return [count, inc];
}

const [count, increment] = useCounter(0);

Labeled tuples — better readability

We can name each slot for documentation. Labels don’t affect the type, just IDE hints.

type Range = [start: number, end: number];
const r: Range = [0, 10]; // hovering shows 'start' and 'end'

Optional and rest in tuples

type Optional = [string, number?];           // second item is optional
type Rest = [string, ...number[]];           // string followed by any number of numbers

const a: Optional = ["x"];      // ✅
const b: Rest = ["tag", 1, 2, 3]; // ✅

readonly arrays — can’t mutate

Adding readonly (or using ReadonlyArray<T>) blocks push, pop, splice, index assignment — anything that mutates.

const fixed: readonly number[] = [1, 2, 3];
fixed.push(4);   // ❌ Property 'push' does not exist on readonly number[]
fixed[0] = 99;   // ❌ Index signature is readonly

Why bother? Function parameters mostly. We signal that we won’t mutate the caller’s array.

function sum(nums: readonly number[]): number {
  // can't accidentally modify nums here
  return nums.reduce((a, b) => a + b, 0);
}

readonly tuples

const point: readonly [number, number] = [1, 2];
point[0] = 5; // ❌

as const — make everything readonly literally

Slapping as const at the end gives us a deeply readonly tuple of literals. Great for constants.

const directions = ["up", "down", "left", "right"] as const;
// type: readonly ["up", "down", "left", "right"]

type Direction = typeof directions[number]; // "up" | "down" | "left" | "right"

When to use which

  • Array T[] — most code. Lists of homogeneous things.
  • Tuple [A, B] — multiple return values, fixed pairs (coordinates, key-value).
  • readonly — parameters we shouldn’t mutate, public constants.
  • as const — derive a union of string literals from an array.

Interview soundbite

“Arrays are homogeneous and variable-length. Tuples are fixed-length with per-position types — great for returning multiple values. readonly prevents mutation, useful for function parameters and shared constants.”


5

Enums

beginner enums const-enum runtime

Enums let us define a named set of constants. In simple language, instead of remembering that 0 means “pending” and 1 means “approved”, we write Status.Pending and Status.Approved.

Enums are the one TypeScript feature that’s NOT just a type — they emit real JavaScript at runtime. That’s both a feature and a footgun.

Numeric enums (default)

Members get auto-incremented numbers starting from 0.

enum Status {
  Pending,    // 0
  Approved,   // 1
  Rejected,   // 2
}

const s: Status = Status.Approved;
console.log(s); // 1

We can set the starting number — the rest auto-increment.

enum HttpCode {
  OK = 200,
  Created,        // 201
  BadRequest = 400,
  Unauthorized,   // 401
}

Numeric enums also create a reverse mapping. Status[1] gives "Approved". That’s why they emit more JS than expected.

String enums

Every member needs an explicit string value. No reverse mapping, but the values are human-readable in logs and DBs.

enum Direction {
  Up = "UP",
  Down = "DOWN",
  Left = "LEFT",
  Right = "RIGHT",
}

const d: Direction = Direction.Up;
console.log(d); // "UP"

String enums are the safest enum — what you see is what you get.

Const enums — zero runtime cost

A regular enum compiles to a JS object. A const enum is inlined at compile time — the enum object disappears entirely.

const enum Color {
  Red,
  Green,
  Blue,
}

const c = Color.Red;
// compiles to:  const c = 0 /* Color.Red */;

No runtime object, faster, smaller bundles. The catch: const enums don’t play well with isolated modules (Babel, esbuild, isolatedModules: true). Many setups disable them.

Why people avoid enums

Three reasons enums get a bad rap:

  1. Numeric enums aren’t type-safe. Any number is assignable to a numeric enum slot.
  2. They emit runtime code. Bigger bundles, weird-looking compiled output.
  3. const enum is brittle. Doesn’t work in all toolchains.

The modern alternative is a union of string literals plus as const:

// Union of literals — no runtime cost, fully type-safe
type Status = "pending" | "approved" | "rejected";
const s: Status = "approved";

// Or, derived from a const object
const Status = {
  Pending: "pending",
  Approved: "approved",
  Rejected: "rejected",
} as const;
type StatusValue = typeof Status[keyof typeof Status];

The only difference is that we don’t get the Status.Pending namespace lookup — but "pending" is just as readable, and we save the JS emit.

When to actually use enums

  • String enums for shared values across services where readability in logs/DB matters.
  • Avoid numeric enums unless interfacing with code that uses numeric flags.
  • Avoid const enums unless you control the whole build pipeline.

Interview soundbite

“Enums emit runtime code, unlike most TS features. String enums are safe, numeric enums have a reverse mapping but aren’t strictly typed, const enums get inlined but don’t work with all bundlers. Many teams prefer union-of-literals plus as const instead.”


6

Type vs Interface

intermediate type interface interview-favorite

This is THE TypeScript interview question. The honest answer: 90% of the time they’re interchangeable for object shapes, but the 10% matters.

In simple language — interface is built for describing the shape of an object. type is more flexible and can describe anything: objects, unions, primitives, tuples, function signatures.

What they have in common

Both describe object shapes. Both work with classes via implements. Both work with generics.

// Interface
interface User {
  id: number;
  name: string;
}

// Type alias — same job
type UserT = {
  id: number;
  name: string;
};

Where they differ

interface
✓ Object shapes
✓ extends (single + multiple)
✓ Declaration merging
✓ implements on classes
✗ Unions / intersections
✗ Primitives, tuples, mapped
type
✓ Object shapes
✓ & (intersection)
✓ | (union)
✓ Primitives, tuples, mapped
✓ Conditional & template literals
✗ Declaration merging

Declaration merging — interface only

Declare an interface twice and TS merges them. This is powerful for extending third-party types (e.g., adding properties to Window).

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

const u: User = { id: 1, name: "Manish" }; // both required ✅

With type, this is an error — types can’t be redeclared.

type UserT = { id: number; };
type UserT = { name: string; }; // ❌ Duplicate identifier

Unions — type only

type can describe anything; interface can only describe an object shape.

type ID = string | number;            // ✅ type can
type Result = { ok: true } | { ok: false; error: string };

// interface ID = string | number;    // ❌ syntax error

Extending

Both can extend, just with different syntax.

// Interface extends interface
interface Animal { name: string; }
interface Dog extends Animal { breed: string; }

// Type uses intersection
type AnimalT = { name: string; };
type DogT = AnimalT & { breed: string; };

Implements (with classes)

Both work, no real difference here.

interface Logger { log(msg: string): void; }
class ConsoleLogger implements Logger {
  log(msg: string) { console.log(msg); }
}

The practical rule

  • Use interface for object shapes that are part of a public API. Declaration merging is a nice escape hatch.
  • Use type when you need unions, intersections, tuples, mapped types, or conditional types.
  • Be consistent within a codebase. Pick a default and stick with it.

A common convention: interface for object shapes, type for everything else.

Performance note

For very large unions, interface (with extends) is slightly faster for the compiler than type with intersection. Mostly irrelevant unless you’re typing thousands of objects.

Interview soundbite

“For plain object shapes they’re nearly identical. The differences: interface supports declaration merging and only describes objects; type is a general alias that can describe unions, primitives, tuples, and mapped types. Use interface for public object APIs, type for everything else.”


Type System Building Blocks

7

Union & Intersection Types

intermediate union intersection composition

Two operators sit at the heart of TypeScript’s type system: | (union) and & (intersection). They look like JS bitwise operators but mean something very different here.

Union — A | B means “either A or B”

A value of type A | B could be either. We can only use properties/methods that exist on BOTH sides until we narrow.

type ID = string | number;

function printId(id: ID) {
  // id.toUpperCase(); // ❌ not all IDs have this
  if (typeof id === "string") {
    console.log(id.toUpperCase()); // ✅ narrowed to string
  } else {
    console.log(id.toFixed(2));    // ✅ narrowed to number
  }
}

Unions are everywhere — function parameters that accept multiple shapes, return types that can be a value or null, state machines.

type LoadState = "idle" | "loading" | "success" | "error";

Intersection — A & B means “both A and B at once”

A value of type A & B has ALL properties from both. Think of it as merging the shapes.

type HasId = { id: number };
type HasName = { name: string };

type User = HasId & HasName;
// User has both id AND name

const u: User = { id: 1, name: "Manish" }; // both required

In simple language — union narrows what we can do (only common stuff), intersection broadens what we have (everything from both).

A | B (union, OR)
Value is A or B
Can use: properties in both
Need narrowing to access either side
e.g. string | number
A & B (intersection, AND)
Value is A and B
Can use: properties from either
No narrowing needed
e.g. HasId & HasName

Combining unions and intersections

Real-world types often mix both. A common pattern is “base props + variant”.

type ButtonBase = { onClick: () => void };
type Primary = { variant: "primary"; label: string };
type Icon = { variant: "icon"; icon: string };

type ButtonProps = ButtonBase & (Primary | Icon);
// every button has onClick, plus EITHER label OR icon depending on variant

Intersection of primitives — never

Intersecting incompatible primitives gives never (no value can be both).

type Weird = string & number; // never

Common mistake: union of objects ≠ intersection of properties

When we have A | B where both are objects, we can only access properties present in BOTH.

type A = { x: number; y: number };
type B = { x: number; z: number };

function f(v: A | B) {
  v.x; // ✅ in both
  // v.y; // ❌ only in A
}

That’s where discriminated unions come in — they let us narrow cleanly. See the next note.

Narrowing unions

We narrow with typeof, instanceof, in, equality checks, or user-defined type guards. See the Type Narrowing note for the full story.

function area(shape: { kind: "circle"; r: number } | { kind: "square"; s: number }) {
  if (shape.kind === "circle") return Math.PI * shape.r ** 2;
  return shape.s ** 2;
}

Interview soundbite

| is union, an OR — value is one of the listed types and we narrow before using specifics. & is intersection, an AND — value has everything from both types. Unions need narrowing; intersections combine shapes.”


8

Literal Types & Const Assertions

intermediate literal-types as-const widening

A literal type is a type that has exactly one possible value. Instead of string, it could be the literal "left". Instead of number, the literal 42. Combine them with unions and we get the safest, most expressive APIs in TS.

What’s a literal type?

let dir: "left" | "right" | "up" | "down";
dir = "left";   // ✅
dir = "north";  // ❌ Type '"north"' is not assignable

In simple language, we’re saying “this variable can only hold one of these exact strings”. It’s a closed set — and TS will flag any typo.

The widening problem

Here’s the catch. const of a literal keeps the literal type, but let widens to the primitive.

const a = "left";  // type: "left" (literal)
let b = "left";    // type: string  (widened)

const dir: "left" | "right" = a; // ✅
const dir2: "left" | "right" = b; // ❌ string not assignable

Same thing happens with object properties — they widen by default.

const config = { mode: "dark" }; // mode is string, not "dark"

type Theme = { mode: "light" | "dark" };
const t: Theme = config; // ❌

This is where as const saves us.

as const — the const assertion

Adding as const tells TS: “treat this exactly as written — don’t widen anything, make every property readonly, treat strings/numbers as literals”.

const config = { mode: "dark" } as const;
// type: { readonly mode: "dark" }

const t: Theme = config; // ✅

Deriving types from data

This is the killer use case. Define data once, derive types from it.

const STATUSES = ["pending", "approved", "rejected"] as const;
// type: readonly ["pending", "approved", "rejected"]

type Status = typeof STATUSES[number];
// type: "pending" | "approved" | "rejected"

We just got a string-literal union from an array, without duplicating values. Add a new status to the array and the type updates automatically.

const ROUTES = {
  home: "/",
  profile: "/profile",
  settings: "/settings",
} as const;

type RoutePath = typeof ROUTES[keyof typeof ROUTES];
// type: "/" | "/profile" | "/settings"

Numeric and boolean literals

Not just strings — numbers and booleans can be literals too.

type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6;
type Coin = "heads" | "tails";
type Toggle = true | false; // same as boolean

function roll(): DiceRoll {
  return (Math.floor(Math.random() * 6) + 1) as DiceRoll;
}

Literal types as function returns

A function returning a literal lets callers branch precisely.

function getEnv(): "dev" | "prod" {
  return process.env.NODE_ENV === "production" ? "prod" : "dev";
}

Combining with discriminated unions

Literals on a shared kind field are how discriminated unions work — see the next note.

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; size: number };

Common gotcha: passing object literals to typed slots

type Btn = { variant: "primary" | "secondary" };

const a = { variant: "primary" };
const btn: Btn = a; // ❌ a.variant is string, not "primary"

const c = { variant: "primary" } as const;
const btn2: Btn = c; // ✅

Interview soundbite

“A literal type is a type with one possible value, like the string 'left' rather than all of string. Combined with unions they make tight, typo-proof APIs. as const stops widening — keeps literals literal and makes objects/arrays deeply readonly. Together they let us derive types from runtime data.”


9

Type Aliases

beginner type-alias reuse naming

A type alias is just a name for a type. In simple language, instead of repeating { id: number; name: string } everywhere, we name it User once and reference it.

The basics

type ID = string | number;
type User = {
  id: ID;
  name: string;
  email: string;
};

const u: User = { id: 1, name: "Manish", email: "x@y.com" };

That’s it. The type keyword followed by a name and an =. Anything to the right of = can be a type expression — primitives, objects, unions, tuples, functions, anything.

Aliasing primitives

Useful when a primitive carries domain meaning.

type Email = string;
type UserId = number;
type Timestamp = number;

function notify(to: Email, at: Timestamp): void { /* ... */ }

Note: this doesn’t make Email a distinct type from string — it’s just a label. Any string will work where Email is expected. For nominal typing (truly distinct types) we need a “branded type” pattern. That’s an advanced topic.

Aliasing function signatures

type Predicate<T> = (value: T) => boolean;

const isEven: Predicate<number> = (n) => n % 2 === 0;
const isShort: Predicate<string> = (s) => s.length < 5;

Aliasing unions

This is where aliases really pay off — naming a union makes it reusable and self-documenting.

type LoadState = "idle" | "loading" | "success" | "error";

function render(state: LoadState) { /* ... */ }

Aliasing tuples

type Point = [x: number, y: number];
type RGB = [number, number, number];

Aliases can reference each other

type ID = string | number;
type Entity = { id: ID; createdAt: number };
type User = Entity & { name: string };
type Post = Entity & { title: string; authorId: ID };

Generic type aliases

Aliases accept type parameters, just like functions accept value parameters.

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

const r: Result<number> = { ok: true, value: 42 };
const e: Result<string> = { ok: false, error: "not found" };

Type alias vs interface (quick recap)

  • interface only describes object shapes.
  • type describes anything — objects, unions, primitives, tuples, function signatures.
  • Aliases can’t be redeclared/merged; interfaces can.

See the “Type vs Interface” note for the full comparison.

Naming conventions

A few common patterns teams use:

  • PascalCase for type names (User, OrderStatus).
  • No I prefix for interfaces (that’s a C# convention, not TS).
  • Props suffix for React component props (ButtonProps).
  • T prefix or just one letter for generics (T, K, V — or TItem if multiple).

Interview soundbite

“A type alias is a name for a type expression. It’s the simplest tool for reuse — define a shape once, use it everywhere. Unlike interfaces, aliases can name any type: unions, tuples, primitives, function signatures. They’re inert at runtime — purely a compile-time label.”


10

Type Narrowing & Type Guards

intermediate narrowing type-guards typeof instanceof in

Narrowing is how TypeScript moves from a broad type (like string | number) to a specific one inside a branch of code. It’s TS’s secret sauce — the compiler reads our control flow and adjusts the type at each step.

In simple language, the compiler runs through our if/else and updates what it thinks the type is, the same way our brain does when reading the code.

typeof — for primitives

The classic. TS recognizes typeof x === "string" | "number" | "boolean" | "undefined" | "object" | "function" | "bigint" | "symbol".

function double(x: string | number): string | number {
  if (typeof x === "number") {
    return x * 2;          // x is number here
  }
  return x.repeat(2);      // x is string here
}

instanceof — for class instances

class Dog { bark() { console.log("woof"); } }
class Cat { meow() { console.log("meow"); } }

function speak(pet: Dog | Cat) {
  if (pet instanceof Dog) {
    pet.bark();   // narrowed to Dog
  } else {
    pet.meow();   // narrowed to Cat
  }
}

in operator — check property existence

Great for narrowing between object types without a discriminator.

type Bird = { fly: () => void };
type Fish = { swim: () => void };

function move(animal: Bird | Fish) {
  if ("fly" in animal) {
    animal.fly();   // Bird
  } else {
    animal.swim();  // Fish
  }
}

Equality narrowing

Comparing to a literal narrows.

function f(x: string | null) {
  if (x === null) return;
  x.toUpperCase(); // x is string here
}

This is also how discriminated unions narrow on a kind/type field.

Truthiness narrowing

if (value) narrows away null, undefined, "", 0, false.

function f(x: string | null | undefined) {
  if (x) {
    x.toUpperCase(); // narrowed to string
  }
}

Be careful — empty string and 0 are falsy. If we mean “not null/undefined”, use x != null (loose equality on purpose — catches both).

User-defined type guards (type predicates)

When the built-in narrowing isn’t enough, we can write our own. A type predicate is a function that returns arg is SomeType.

type Cat = { meow: () => void };
type Dog = { bark: () => void };

function isCat(animal: Cat | Dog): animal is Cat {
  return "meow" in animal;
}

function pet(animal: Cat | Dog) {
  if (isCat(animal)) {
    animal.meow(); // narrowed to Cat
  } else {
    animal.bark(); // narrowed to Dog
  }
}

The signature animal is Cat is a promise to the compiler: “if I return true, treat the argument as Cat from here on”. TS trusts us — make sure the runtime check actually matches.

Assertion functions

Cousin of type predicates. Throws if the check fails, narrows on the rest of the function after the call.

function assertIsString(val: unknown): asserts val is string {
  if (typeof val !== "string") throw new Error("Not a string");
}

function f(input: unknown) {
  assertIsString(input);
  input.toUpperCase(); // narrowed to string here on
}

Discriminated unions

The cleanest narrowing pattern — a shared literal field that uniquely identifies each variant. Big enough topic to have its own note.

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; size: number };

function area(s: Shape): number {
  if (s.kind === "circle") return Math.PI * s.radius ** 2;
  return s.size ** 2;
}

never for exhaustiveness

A handy pattern — in the default branch of a switch, assign to never. If a new variant is added later, TS errors here.

function area(s: Shape): number {
  switch (s.kind) {
    case "circle": return Math.PI * s.radius ** 2;
    case "square": return s.size ** 2;
    default:
      const _exhaustive: never = s; // ❌ if Shape grows
      return _exhaustive;
  }
}

Interview soundbite

“Narrowing is TS reading our control flow and shrinking a broad type inside branches. Built-in tools: typeof, instanceof, in, equality, truthiness. For custom checks we write user-defined type guards returning arg is Type. The pattern matters because it’s how we work safely with union types.”


11

Discriminated Unions

intermediate discriminated-union tagged-union exhaustiveness never

A discriminated union (also called a tagged union or algebraic data type) is a union of object types that all share one field — the discriminant — whose value is a literal type. That shared field tells TS exactly which variant we have.

In simple language: every shape carries a tag (kind, type, status, whatever) that uniquely identifies it. Check the tag, get the right type.

The shape of it

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

Every variant has a kind field with a unique string literal. Now TS can tell them apart just by checking kind.

shape.kind
"circle"
→ radius: number
"square"
→ size: number
"rectangle"
→ width, height

Narrowing on the tag

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

Inside each case, TS knows the exact variant. No casts, no as, no runtime type checks beyond the tag comparison.

Exhaustiveness check with never

The killer feature. Add a default case that assigns to never — if we ever extend the union and forget a case, TS errors right here.

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle": return Math.PI * shape.radius ** 2;
    case "square": return shape.size ** 2;
    case "rectangle": return shape.width * shape.height;
    default:
      const _exhaustive: never = shape; // ❌ if Shape gains a variant
      throw new Error(`Unhandled shape: ${_exhaustive}`);
  }
}

Add { kind: "triangle"; ... } to Shape later — every switch that doesn’t handle it lights up red. Compile-time refactor safety.

Real-world pattern: API results

This is the single most common use case. A request can succeed OR fail; the success and failure shapes are different.

type ApiResult<T> =
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; message: string };

function render(result: ApiResult<User>) {
  if (result.status === "loading") return "Loading...";
  if (result.status === "error")   return `Error: ${result.message}`;
  return `Hello ${result.data.name}`; // status is "success"
}

Notice how we never access data accidentally on a loading result — the type system blocks it.

Real-world pattern: Redux/state machine actions

type Action =
  | { type: "increment" }
  | { type: "decrement" }
  | { type: "set"; value: number };

function reducer(state: number, action: Action): number {
  switch (action.type) {
    case "increment": return state + 1;
    case "decrement": return state - 1;
    case "set":       return action.value; // safely access value
  }
}

Picking the discriminant

  • Use a string literal field (kind, type, status). Not numeric — strings are clearer in logs.
  • One field per union. Don’t try to discriminate on two fields at once.
  • Same field name across all variants — that’s what makes the discrimination work.

Why not just instanceof?

Discriminated unions work with plain objects — no classes needed. They serialize cleanly to JSON, survive network boundaries, and are the standard pattern in functional languages (Rust enums, Haskell ADTs, OCaml variants).

Interview soundbite

“A discriminated union is a union of object types sharing one literal-typed field. The compiler narrows each branch based on that field — no casts, no runtime overhead. The never exhaustiveness pattern guarantees we handle every case, and any new variant added later breaks the build until we cover it.”


Functions

12

Function Types & Signatures

intermediate functions types signatures

In simple language, a function type tells us what a function takes in and what it gives back. TypeScript lets us describe that shape so we can pass functions around safely.

The simplest way is to annotate the parameters and return type directly on the function declaration. The compiler then yells at us if we call it wrong.

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

// inferred return type — TS figures it out
function double(x: number) {
  return x * 2; // inferred as number
}

Function type expressions

When we want to pass a function as a parameter (think: callbacks), we describe its shape with an arrow-style type. This is the most common pattern we’ll see.

type Greeter = (name: string) => string;

const hello: Greeter = (name) => `Hi, ${name}`;

function run(fn: (n: number) => boolean) {
  return fn(42);
}

The only difference between this and a regular type alias is the => — that’s how we say “this is a function that returns X”.

Call signatures (the object form)

Sometimes a function also has properties on it (like express’s app() which is callable but also has app.get). For those, we use call signatures inside an object type.

type CountedFn = {
  calls: number;
  (x: number): number; // call signature — no `=>`
};

function makeCounter(): CountedFn {
  const fn = ((x: number) => x * 2) as CountedFn;
  fn.calls = 0;
  return fn;
}

Construct signatures

For things meant to be called with new, we use new (...). Rare in app code, common in library typings.

type CtorOf<T> = new (...args: any[]) => T;

function create<T>(C: CtorOf<T>): T {
  return new C();
}

void vs undefined return

A void return means “the caller shouldn’t rely on the return value”. It does NOT mean the function must return nothing — it just means the value is ignored. This catches people off guard in interviews.

type Logger = (msg: string) => void;

const log: Logger = (msg) => msg.length; // OK — return is ignored

Why this matters

In real code, function signatures are the contract between modules. Get them right and refactors stay safe; get them wrong and runtime errors leak through. A common interview question: “What’s the difference between a function type expression and a call signature?” — expressions are for plain callable types, call signatures let us add properties.


13

Optional, Default & Rest Parameters

beginner functions parameters

In simple language, sometimes we don’t want every parameter to be mandatory. TypeScript gives us three tools for that: optional (?), default values, and rest (...).

Optional parameters

Add a ? after the name and the parameter becomes optional. Inside the function, its type becomes T | undefined, so we still need to handle the “not passed” case.

function greet(name: string, title?: string): string {
  // title is string | undefined here
  return title ? `${title} ${name}` : name;
}

greet("Manish");         // OK
greet("Manish", "Dr.");  // OK

Rule: optional parameters must come AFTER required ones. function bad(a?: string, b: number) is a compile error.

Default parameters

A default value makes the parameter optional AND gives it a fallback. Now we don’t need the | undefined check inside.

function greet(name: string, title: string = "Mr."): string {
  return `${title} ${name}`; // title is just string, never undefined
}

greet("Manish");           // "Mr. Manish"
greet("Manish", "Dr.");    // "Dr. Manish"
greet("Manish", undefined); // "Mr. Manish" — undefined triggers default

Defaults can be after required params too, and they can even reference earlier parameters. They don’t need to be at the end IF the call site passes undefined explicitly.

Rest parameters

When we don’t know how many arguments will come in, we collect them into an array with .... Think of it like the opposite of spread.

function sum(...nums: number[]): number {
  return nums.reduce((a, b) => a + b, 0);
}

sum(1, 2, 3);       // 6
sum();              // 0
sum(...[1, 2, 3]);  // 6 — spread back in

Rest must be the last parameter, and its type is always an array (or tuple).

Tuples in rest — the powerful trick

We can type rest params as a tuple to enforce specific positions. Super useful for things like event emitters.

type EventArgs = [event: string, payload: object];

function emit(...args: EventArgs) {
  const [event, payload] = args;
  console.log(event, payload);
}

emit("user.created", { id: 1 }); // OK

Common interview gotcha

What’s the difference between param?: string and param: string | undefined? The optional version lets us SKIP the argument at the call site; the union version forces us to pass undefined explicitly. Subtle but it shows up in API design.

function a(x?: string) {}
function b(x: string | undefined) {}

a();           // OK
b();           // Error — expected 1 argument
b(undefined);  // OK

14

Function Overloading

intermediate functions overloading

In simple language, function overloading lets one function accept several different argument patterns, each with its own return type. TypeScript only does this at the type level — at runtime there’s still just one function.

The pattern: write 2+ “overload signatures” with no body, then ONE “implementation signature” with the body. Callers only see the overloads; the implementation is hidden.

// overload signatures (visible to callers)
function parse(input: string): object;
function parse(input: number): string;

// implementation signature (hidden, must be compatible with all overloads)
function parse(input: string | number): object | string {
  if (typeof input === "string") return JSON.parse(input);
  return input.toString();
}

const a = parse('{"x":1}'); // a: object
const b = parse(42);        // b: string

Why not just use unions?

Good question — and often a union is enough. The difference shows up when the return type DEPENDS on the input type.

// union version — return type is always the same
function bad(x: string | number): string | number {
  return x; // caller always gets string | number
}

// overload version — return narrows based on input
function good(x: string): string;
function good(x: number): number;
function good(x: string | number) { return x; }

const s = good("hi"); // s: string  ← narrowed!
const n = good(42);   // n: number

Rules to remember

  1. The implementation signature is NOT callable from outside. Only the overloads are.
  2. The implementation signature must be a superset of all overloads (its params/return must satisfy every overload).
  3. Order matters — TypeScript picks the FIRST matching overload, so put the most specific one first.
function fmt(x: boolean): "yes" | "no";
function fmt(x: number): string;
function fmt(x: boolean | number): string {
  return typeof x === "boolean" ? (x ? "yes" : "no") : x.toFixed(2);
}

When to prefer overloading

  • The return type changes based on input shape.
  • Different parameter counts (e.g., slice(start) vs slice(start, end)).
  • Mutually exclusive options (e.g., either pass id OR name, never both).

When to AVOID overloading

If a union or generic does the job, use that. Overloads are duplicate-y and easy to get out of sync. Modern advice: try function f<T extends ...> or conditional types first.

// often cleaner than overloads:
function pick<T extends "yes" | "no" | number>(x: T): T {
  return x;
}

Common interview prompt

“Write a function combine that returns string when both args are strings and number when both are numbers.” That’s a textbook overload — or a generic with conditional types, depending on how fancy we want to be.


Objects & Classes

15

Interfaces

intermediate interfaces types objects

In simple language, an interface describes the shape of an object — what properties it has and what types they are. It’s a contract: “anything that wants to be a User must have these fields”.

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

const u: User = { id: 1, name: "Manish", email: "m@x.com" };

Optional and readonly

? marks a property as optional. readonly makes it immutable after creation — assignment after init is a compile error.

interface Post {
  readonly id: number;   // can't reassign after creation
  title: string;
  draft?: boolean;       // may or may not exist
}

const p: Post = { id: 1, title: "Hi" };
p.title = "Hey";  // OK
p.id = 2;         // Error — readonly

readonly is shallow — nested objects are still mutable.

Extending interfaces

One interface can extend another (or multiple). Think of it like the only difference is we’re stacking shapes.

interface Animal {
  name: string;
}

interface Dog extends Animal {
  breed: string;
}

interface ServiceDog extends Dog, Trainable {
  serviceType: string;
}

interface Trainable {
  trainerId: number;
}

Index signatures

When we don’t know all the keys ahead of time (think: a dictionary), we use an index signature. The key can be string, number, or symbol.

interface Translations {
  [locale: string]: string;
}

const t: Translations = {
  en: "Hello",
  hi: "Namaste",
};

t.es = "Hola"; // OK — any string key works

We can also mix named props with an index signature, but the named props must be assignable to the index value type.

interface Config {
  version: string;        // known
  [key: string]: string;  // anything else is also string
}

Interface vs type alias

Both can describe object shapes. Key differences:

  • Interfaces can be re-opened (declaration merging) — handy for extending library types.
  • Type aliases support unions, intersections, mapped types, conditional types.
interface Box { width: number; }
interface Box { height: number; } // merged!
// Box now requires { width, height }

type Status = "ok" | "fail"; // union — only type can do this

Rule of thumb: use interface for object shapes meant to be extended/implemented, type for unions, primitives, and computed types.

Implementing interfaces

Classes use implements to promise they match the shape. We’ll see this in the abstract classes note.

interface Printable {
  print(): void;
}

class Doc implements Printable {
  print() { console.log("printing..."); }
}

Interview gotcha

Excess property checks: object literals get extra-strict checks, but variables don’t. { id: 1, name: "x", extra: true } as User triggers an error, but assigning a variable with that shape doesn’t. This trips people up.


16

Classes & Access Modifiers

intermediate classes oop encapsulation

In simple language, access modifiers say “who’s allowed to touch this member”. TypeScript has four: public, private, protected, and readonly. They’re enforced at compile time only — at runtime, JavaScript doesn’t care.

class Account {
  public id: number;          // anyone can read/write
  private balance: number;    // only this class
  protected ownerId: number;  // this class + subclasses
  readonly createdAt: Date;   // anyone can read, no one can write after init

  constructor(id: number, ownerId: number) {
    this.id = id;
    this.balance = 0;
    this.ownerId = ownerId;
    this.createdAt = new Date();
  }
}

Visibility table

modifier
same class
subclass
outside
public
yes
yes
yes
protected
yes
yes
no
private
yes
no
no

public — the default

If we don’t write a modifier, it’s public. Anyone can read and write.

private — class only

Only the class itself can access. Subclasses can’t either. Useful for internal state we don’t want anyone touching.

class Counter {
  private count = 0;
  inc() { this.count++; }
  get value() { return this.count; }
}

const c = new Counter();
c.count; // Error — private

protected — class + subclasses

Same as private, but subclasses can also see it. Common for shared helpers in inheritance hierarchies.

class Animal {
  protected speakVerb = "makes a sound";
  describe() { return `Animal ${this.speakVerb}`; }
}

class Dog extends Animal {
  bark() { return `Dog ${this.speakVerb}`; } // OK — protected reaches subclasses
}

readonly

Orthogonal to the others — can combine with public/private/protected. Means “no reassignment after constructor”.

class Config {
  readonly version = "1.0";
  private readonly secret: string;
  constructor(s: string) { this.secret = s; }
}

TS private vs JS #private

TypeScript’s private is compile-time only — at runtime, the property is just a regular field. JavaScript’s #private (with the hash) is runtime-enforced and totally invisible from outside.

class A {
  private x = 1;     // accessible via (a as any).x at runtime
  #y = 2;            // truly private, even at runtime
}

In interviews, this distinction matters — #y is preferred for real encapsulation in modern code.

Common gotcha

Two private fields with the same name across unrelated classes don’t clash, but TypeScript’s structural typing makes private members nominal — two classes with identical shapes but private fields are NOT assignable to each other. That’s intentional.


17

Abstract Classes & Implementing Interfaces

intermediate classes oop abstract interfaces

In simple language, an abstract class is a half-finished class. It can have real methods AND placeholder methods that subclasses must implement. We can’t new it directly — only its subclasses.

abstract class Shape {
  abstract area(): number;       // must be implemented by subclass

  describe(): string {           // real method — subclasses inherit
    return `area is ${this.area()}`;
  }
}

class Circle extends Shape {
  constructor(private r: number) { super(); }
  area() { return Math.PI * this.r * this.r; }
}

const c = new Circle(5);
new Shape(); // Error — cannot create instance of abstract class

Abstract vs interface — when to use which

The only difference, basically:

  • Interface: pure contract, no implementation, zero runtime presence.
  • Abstract class: contract + shared implementation + runtime presence (you can have a constructor, fields, methods).

If we only need to describe a shape, interface. If we need shared code AND a contract, abstract class.

// shape contract only
interface Greeter {
  greet(): string;
}

// contract + shared logic
abstract class BaseGreeter {
  abstract name: string;
  greet() { return `Hi, ${this.name}`; }
}

implements — promising to match an interface

A class can promise it satisfies one or more interfaces. The compiler then verifies the shape matches.

interface Loggable {
  log(): void;
}

interface Serializable {
  toJSON(): string;
}

class User implements Loggable, Serializable {
  constructor(public name: string) {}
  log() { console.log(this.name); }
  toJSON() { return JSON.stringify({ name: this.name }); }
}

Important: implements is just a check. It does NOT change the class — it only verifies. So adding implements Loggable doesn’t give us a log method; we still have to write it.

extends vs implements

  • extends: inherit from ONE class (gets fields, methods, prototype chain).
  • implements: satisfy any number of interfaces (gets nothing, just checked).

A class can extends one class and implements multiple interfaces at the same time. Think of it like the only difference is what we get back — inheritance gives code, implementation gives a check.

class Admin extends User implements Loggable, Serializable {
  // ...
}

Abstract + protected combo

A common pattern: keep abstract methods protected so only subclasses (and the abstract class itself) can call them, while exposing a public template method.

abstract class Pipeline {
  run() {
    this.before();
    this.step();   // subclass fills this in
    this.after();
  }
  protected before() {}
  protected after() {}
  protected abstract step(): void;
}

class Etl extends Pipeline {
  protected step() { console.log("extracting..."); }
}

This is the Template Method pattern — common interview question.

Why abstract classes still matter

Even with interfaces being so powerful, abstract classes are great when:

  • We need shared state (fields, not just methods).
  • We have a common algorithm with customizable steps.
  • We want to enforce constructor logic for all subclasses.

If none of those apply, interface is lighter and works just as well.


18

Static Members & Parameter Properties

intermediate classes static constructor shortcuts

Two unrelated but very useful class features bundled into one note: static members and parameter properties.

Static members

In simple language, static means “this belongs to the class, not to instances”. Think of it like the only difference is whether we call it on the class itself or on an object made from the class.

class MathHelper {
  static PI = 3.14159;
  static square(n: number) { return n * n; }
}

MathHelper.PI;           // 3.14159
MathHelper.square(4);    // 16

const m = new MathHelper();
m.PI; // Error — PI is on the class, not the instance

Static fields are great for:

  • Constants (HttpStatus.OK = 200).
  • Counters shared across instances.
  • Factory methods (User.fromJSON(...)).
  • Singleton patterns.
class IdGenerator {
  private static counter = 0;
  static next() { return ++IdGenerator.counter; }
}

IdGenerator.next(); // 1
IdGenerator.next(); // 2

Static members can be public, private, protected, and readonly — same modifiers as instance members.

Static blocks

Modern TS supports static initialization blocks for setup that needs more than a single expression.

class Config {
  static envs: string[];
  static {
    Config.envs = process.env.NODE_ENV?.split(",") ?? [];
  }
}

Parameter properties — the constructor shortcut

This one saves SO much boilerplate. Adding an access modifier (or readonly) to a constructor parameter automatically declares AND assigns it as a field.

// the long way
class User {
  name: string;
  age: number;
  constructor(name: string, age: number) {
    this.name = name;
    this.age = age;
  }
}

// the short way — parameter properties
class User2 {
  constructor(public name: string, public age: number) {}
}

Both produce the exact same class. The second version is way nicer for DTO-style classes.

Any modifier works — public, private, protected, readonly, or combinations:

class Account {
  constructor(
    public readonly id: number,
    private balance: number,
    protected ownerId: number,
  ) {}
}

Mixing parameter properties with regular params

We can mix freely — params with modifiers become fields, params without modifiers stay as plain locals.

class Order {
  constructor(
    public id: number,           // becomes field
    items: string[],             // just a local
  ) {
    console.log(items.length);   // use locally, then it's gone
  }
}

Interview gotchas

  1. Static this: inside a static method, this refers to the CLASS, not an instance. Useful for static factories that need to be inheritable.
class Base {
  static create<T extends typeof Base>(this: T): InstanceType<T> {
    return new this() as InstanceType<T>;
  }
}

class Child extends Base {}
const c = Child.create(); // c: Child
  1. Parameter properties don’t work with destructuringconstructor(public { a, b }: { a: number, b: number }) is not allowed. We have to destructure inside the body.

  2. Static and instance with the same name are allowed — they live in different scopes. Confusing but legal.


Generics

19

Generic Functions & Type Parameters

intermediate generics functions type-parameters

In simple language, a generic is a placeholder for a type. We write the function once, and the caller (or the compiler) fills in the actual type. Think of it like a function parameter, but for types instead of values.

The classic example: an identity function that returns whatever it’s given.

// without generics — we lose type info
function identityBad(x: any): any {
  return x;
}
const a = identityBad("hi"); // a: any  — we lost "string"!

// with generics — type flows through
function identity<T>(x: T): T {
  return x;
}
const b = identity("hi"); // b: string — TS inferred T = string
const c = identity(42);   // c: number

The <T> is the type parameter. By convention we start with T, then U, V, etc. — but feel free to use descriptive names like <Key, Value>.

Why generics matter

They let us write reusable code WITHOUT falling back to any. The compiler still knows the real types, so autocomplete, refactoring, and error checking all keep working.

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

const n = first([1, 2, 3]);     // n: number | undefined
const s = first(["a", "b"]);    // s: string | undefined

Multiple type parameters

We can have as many as we need. Common pattern: mapping one type to another.

function pair<A, B>(a: A, b: B): [A, B] {
  return [a, b];
}

const p = pair("manish", 28); // p: [string, number]

Type inference vs explicit type arguments

Usually TS figures out the type from the arguments. If it can’t (or guesses wrong), we pass it explicitly.

function wrap<T>(x: T): T[] { return [x]; }

wrap(5);            // T inferred as number
wrap<string>("hi"); // T explicit — useful when no arg helps inference

Generic function expressions

Same syntax, different position:

const map = <T, U>(arr: T[], fn: (x: T) => U): U[] =>
  arr.map(fn);

const lengths = map(["abc", "de"], (s) => s.length);
// lengths: number[]

Generic in callback positions

This is where generics really shine — typing higher-order functions.

function withRetry<T>(fn: () => T, tries: number): T {
  for (let i = 0; i < tries - 1; i++) {
    try { return fn(); } catch {}
  }
  return fn(); // last try — let it throw
}

const result = withRetry(() => fetch("/api"), 3); // result: Response

Common interview question

“Write a typed version of Array.prototype.map.” That’s basically the map example above — and it’s a one-liner that tests both generics AND callback typing.

Gotcha: generics in arrow functions inside .tsx

In .tsx files, const f = <T>(x: T) => x is parsed as JSX. Workarounds: const f = <T,>(x: T) => x (trailing comma) or const f = <T extends unknown>(x: T) => x. Not relevant for .ts files.

Generics are the foundation for everything in the next two notes (constraints, generic classes), so this one’s worth getting solid.


20

Generic Constraints (extends)

intermediate generics constraints extends

In simple language, a bare <T> accepts literally any type — which sometimes means we can’t do anything useful inside the function. Generic constraints (T extends Something) say “T can be anything, but at minimum it has these properties”.

// bare generic — we don't know T has .length
function logLength<T>(x: T) {
  console.log(x.length); // Error — Property 'length' does not exist on type 'T'
}

// constrained — T must have .length
function logLength2<T extends { length: number }>(x: T) {
  console.log(x.length); // OK
}

logLength2("hello");       // OK — strings have length
logLength2([1, 2, 3]);     // OK — arrays have length
logLength2({ length: 5 }); // OK — duck-typed
logLength2(42);            // Error — number has no length

How to think about constraints

flexibility vs. capability tradeoff
<T>
accepts: any type
can do: nothing T-specific
<T extends X>
accepts: anything matching X
can do: anything X allows
specific type
accepts: only that type
can do: everything

Constraint referring to another type parameter

We can have one type parameter constrain another. Very common when we want a “key of an object” type-safe getter.

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

const user = { name: "Manish", age: 28 };
const n = getProp(user, "name"); // n: string
const a = getProp(user, "age");  // a: number
getProp(user, "xyz");            // Error — "xyz" is not a key of user

This is one of the most-asked patterns in interviews — typed property access.

Constraints with default types

We can give a type parameter a default, used when the caller doesn’t specify one.

function createState<T = string>(initial: T): { value: T } {
  return { value: initial };
}

const a = createState("hi");        // T inferred as string
const b = createState<number>(0);   // T explicit
const c = createState();            // Error — initial required, but T defaults to string

Constraining to unions

We can require T to be one of a set of literal types.

type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";

function request<M extends HttpMethod>(method: M, url: string) {
  return { method, url };
}

request("GET", "/users");  // OK
request("PATCH", "/x");    // Error — not in the union

extends in conditionals — quick mention

extends also appears in conditional types (T extends U ? X : Y). Same keyword, different role — there it’s a type-level “is T assignable to U” check, not a constraint. We’ll dig into that in the conditional types note.

Common interview question

“Implement a type-safe pick(obj, ...keys).” The answer uses K extends keyof T and a rest tuple:

function pick<T, K extends keyof T>(obj: T, ...keys: K[]): Pick<T, K> {
  const out = {} as Pick<T, K>;
  for (const k of keys) out[k] = obj[k];
  return out;
}

const u = { id: 1, name: "M", age: 28 };
const p = pick(u, "id", "name"); // p: { id: number; name: string }

Constraints are what take generics from “neat” to “actually useful in production”.


21

Generic Classes & Interfaces

intermediate generics classes interfaces data-structures

In simple language, generic functions let one function work with many types. Generic classes and interfaces do the same thing for whole objects — a Stack<number> and a Stack<string> share one definition but stay totally type-safe.

Generic interfaces

The type parameter goes right after the interface name. Inside, we can use it anywhere a type goes.

interface ApiResponse<T> {
  data: T;
  status: number;
  error?: string;
}

const r1: ApiResponse<string> = { data: "ok", status: 200 };
const r2: ApiResponse<{ id: number }> = { data: { id: 1 }, status: 200 };

The classic example: an interface for any container.

interface Box<T> {
  contents: T;
}

const numBox: Box<number> = { contents: 42 };
const strBox: Box<string> = { contents: "hi" };

Generic classes

Same pattern — type parameter after the class name, available throughout the class body.

class Stack<T> {
  private items: T[] = [];
  push(item: T) { this.items.push(item); }
  pop(): T | undefined { return this.items.pop(); }
  peek(): T | undefined { return this.items[this.items.length - 1]; }
  get size() { return this.items.length; }
}

const numStack = new Stack<number>();
numStack.push(1);
numStack.push("two"); // Error — must be number

const strStack = new Stack<string>();
strStack.push("hi"); // OK

Multiple type parameters

A Map<K, V> is the obvious one. Let’s write our own simplified version.

class Dictionary<K extends string | number, V> {
  private store: Record<string, V> = {};
  set(key: K, value: V) { this.store[String(key)] = value; }
  get(key: K): V | undefined { return this.store[String(key)]; }
}

const d = new Dictionary<string, number>();
d.set("age", 28);
const a = d.get("age"); // a: number | undefined

Implementing a generic interface

A class can implement a generic interface — and either lock the type in or stay generic itself.

interface Repository<T> {
  find(id: number): T | null;
  save(item: T): void;
}

// locked-in: user-specific repo
class UserRepo implements Repository<{ id: number; name: string }> {
  find(id: number) { return { id, name: "M" }; }
  save(item: { id: number; name: string }) { /* ... */ }
}

// stays generic: works for any T
class InMemoryRepo<T extends { id: number }> implements Repository<T> {
  private items: T[] = [];
  find(id: number) { return this.items.find(i => i.id === id) ?? null; }
  save(item: T) { this.items.push(item); }
}

Extending generic classes

A subclass can pass through the generic, lock it down, or add its own.

class Queue<T> {
  protected items: T[] = [];
  enqueue(x: T) { this.items.push(x); }
  dequeue(): T | undefined { return this.items.shift(); }
}

// lock T to string
class StringQueue extends Queue<string> {
  joinAll() { return this.items.join(","); }
}

// pass T through
class LoggedQueue<T> extends Queue<T> {
  enqueue(x: T) {
    console.log("adding", x);
    super.enqueue(x);
  }
}

Default type parameters

Just like with functions, classes and interfaces can have defaults.

interface Result<T = unknown, E = Error> {
  ok: boolean;
  value?: T;
  error?: E;
}

const r: Result = { ok: false, error: new Error("boom") };
// T defaults to unknown, E to Error

Common interview prompt

“Build a typed event emitter where .on('foo', handler) only accepts handlers matching the payload of foo.” That’s a generic interface keyed by event name plus a K extends keyof Events constraint — combines everything from the last three notes.

interface Events {
  login: { userId: number };
  logout: { reason: string };
}

class Emitter<E> {
  on<K extends keyof E>(event: K, handler: (payload: E[K]) => void) {
    /* register */
  }
}

const e = new Emitter<Events>();
e.on("login", (p) => p.userId);  // p: { userId: number }
e.on("logout", (p) => p.reason); // p: { reason: string }

Once we’re comfortable here, the rest of TypeScript’s advanced types — utility types, conditional types, mapped types — all build on this same foundation.


Advanced Types

22

keyof, typeof & Indexed Access Types

advanced keyof typeof indexed-access advanced-types

These three are the building blocks of every fancy TypeScript pattern we’ll write. In simple language: keyof gives us the keys of a type, typeof gives us the type of a value, and indexed access (T[K]) gives us the type of a property. Combine them and we can describe almost anything.

keyof — grab the keys as a union

keyof T returns a union of all the property names of T as string (or number) literals. Think of it like Object.keys() but at the type level.

type User = { id: number; name: string; email: string };

type UserKey = keyof User; // "id" | "name" | "email"

function getField(user: User, key: keyof User) {
  return user[key]; // safe — key is one of the real keys
}

The big win is that keyof follows the type around. If we add a field to User, UserKey updates automatically — no manual sync.

typeof — promote a value into a type

typeof (the TS one, not the JS one) takes a runtime value and asks “what is the type of this thing?”. Super useful when we have a config object or constant and want to derive a type from it instead of typing it twice.

const config = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
  retries: 3,
};

type Config = typeof config;
// { apiUrl: string; timeout: number; retries: number }

The only difference from keyof is that typeof operates on values, while keyof operates on types. Chain them and we get the killer combo: keyof typeof config.

Indexed access — drill into a property’s type

T[K] reads the type of property K from type T. Just like bracket access in JS, except at the type level.

type User = { id: number; name: string; address: { city: string } };

type IdType = User["id"];           // number
type CityType = User["address"]["city"]; // string — we can nest
type IdOrName = User["id" | "name"];     // number | string — union of keys works too
type User = { id: number; name: string }
keyof User
"id" | "name"
User["id"]
number
User[keyof User]
number | string

The killer combo

The real power shows up when we chain all three. Here’s a type-safe pick function — the kind of thing interviewers love.

function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
  return items.map((item) => item[key]);
}

const users = [{ id: 1, name: "Ada" }, { id: 2, name: "Linus" }];
const names = pluck(users, "name"); // string[]
const ids = pluck(users, "id");     // number[]
// pluck(users, "email");          // error — "email" not in keyof T

K extends keyof T constrains K to actual keys of T. The return type T[K][] uses indexed access to figure out what each value is. We get full type safety with zero duplication.

Arrays and number indexing

For arrays and tuples, indexed access with number gives the element type. Handy when we want to pull an element out of an array type.

const roles = ["admin", "editor", "viewer"] as const;
type Role = typeof roles[number]; // "admin" | "editor" | "viewer"

We just turned a runtime array into a union type. This pattern shows up everywhere — defining allowed values once and reusing them as both data and type.


23

Mapped Types

advanced mapped-types advanced-types transformations

In simple language, a mapped type is a for loop over the keys of a type. We take an existing type, iterate over its keys, and produce a new type where each property has been transformed somehow.

The syntax looks weird at first: { [K in keyof T]: ... }. Read it as “for each key K in the keys of T, give me back a property with this new shape”. It’s the type-level equivalent of Object.fromEntries(Object.keys(obj).map(...)).

The basic shape

type User = { id: number; name: string; email: string };

// Make every property optional — this is basically how Partial<T> is built
type PartialUser = { [K in keyof User]?: User[K] };
// { id?: number; name?: string; email?: string }

Notice three things: K in keyof User walks the keys, ? adds optionality, and User[K] is indexed access to grab the original property type. Swap those modifiers around and we get a whole family of transforms.

{ [K in keyof T]: Transform<T[K]> }
Input
{ id: number;
  name: string }
Output (readonly)
{ readonly id: number;
  readonly name: string }

Mapping modifiers — ? and readonly

We can add or remove modifiers with + (default) or -. The - is the cool one because it strips modifiers that already exist.

type MakeOptional<T> = { [K in keyof T]?: T[K] };
type MakeRequired<T> = { [K in keyof T]-?: T[K] }; // strip optional
type Freeze<T> = { readonly [K in keyof T]: T[K] };
type Thaw<T> = { -readonly [K in keyof T]: T[K] };  // strip readonly

type LooseConfig = { host?: string; port?: number };
type StrictConfig = MakeRequired<LooseConfig>; // { host: string; port: number }

This is exactly how Partial, Required, Readonly are implemented in the standard lib — it’s just a thin wrapper around a mapped type.

Key remapping with as

TS 4.1+ lets us rename keys during mapping using as. Combine it with template literal types and we can build getter shapes, event handlers, anything name-based.

type User = { name: string; age: number };

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

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

The as clause runs per-key and decides the new key name. If we return never from the as, that key gets filtered out — which is how we exclude properties.

type RemoveKey<T, K extends keyof T> = {
  [P in keyof T as P extends K ? never : P]: T[P];
};

type WithoutEmail = RemoveKey<{ id: number; email: string }, "email">;
// { id: number }

Mapping a union of strings

K in doesn’t have to start from keyof — any union of keys works. Useful for building records out of thin air.

type Role = "admin" | "editor" | "viewer";

type RolePermissions = { [R in Role]: string[] };
// { admin: string[]; editor: string[]; viewer: string[] }

const perms: RolePermissions = {
  admin: ["read", "write", "delete"],
  editor: ["read", "write"],
  viewer: ["read"],
};

This is basically Record<Role, string[]> written long-hand — and yes, Record itself is just a mapped type.

Real-world: building a form state type

Here’s the kind of pattern that shows up in actual code. Given a form’s value shape, derive matching error and touched maps automatically.

type FormValues = { email: string; password: string; remember: boolean };

type FormErrors<T> = { [K in keyof T]?: string };
type FormTouched<T> = { [K in keyof T]: boolean };

type LoginForm = {
  values: FormValues;
  errors: FormErrors<FormValues>;     // { email?: string; ... }
  touched: FormTouched<FormValues>;   // { email: boolean; ... }
};

If we add a new field to FormValues, the errors and touched types update for free. That’s the whole point of mapped types — write the shape once, derive everything else.


24

Conditional Types & infer

advanced conditional-types infer advanced-types

In simple language, a conditional type is an if-else that runs at compile time. The syntax is T extends U ? X : Y — read as “if T is assignable to U, the type is X, otherwise it’s Y”. That’s it. Everything else is just clever use of this one shape.

The basic branching

type IsString<T> = T extends string ? true : false;

type A = IsString<"hello">;  // true
type B = IsString<42>;       // false
type C = IsString<string>;   // true

Notice we get a literal true or false back — not just boolean. That’s because TS evaluates the branch and picks one. We can use this to narrow types based on what was passed in.

T extends U ? X : Y
T extends U?
yes ↓
type = X
no ↓
type = Y

Distributive behavior — the gotcha

When the thing on the left of extends is a “naked” generic and we pass it a union, the conditional distributes over the union. It runs the conditional once per member and unions the results.

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

type Result = ToArray<string | number>;
// Distributes: ToArray<string> | ToArray<number>
// = string[] | number[]
// NOT (string | number)[]

To prevent distribution, wrap both sides in a tuple. This is a real-world trick used all over library types.

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

type Result2 = ToArrayNoDistribute<string | number>;
// (string | number)[]

infer — extract a type from a position

infer is the real magic. It lets us pattern-match on a type and pull out a piece of it. Think of it as a wildcard with a name — TS figures out what fits in that slot and binds it to the variable.

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

type R1 = MyReturnType<() => string>;          // string
type R2 = MyReturnType<(x: number) => Date>;   // Date
type R3 = MyReturnType<"not a function">;      // never

infer R says “match whatever appears here and call it R”. If the pattern matches, we get R in the true branch. This is literally how TS’s built-in ReturnType<T> is defined.

Real-world infer patterns

These show up everywhere — knowing them is interview gold.

// Get the type a Promise resolves to
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type X = UnwrapPromise<Promise<User>>;  // User

// Get the first parameter of a function
type FirstParam<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never;
type Y = FirstParam<(name: string, age: number) => void>; // string

// Get the element type of an array
type ElementOf<T> = T extends (infer E)[] ? E : never;
type Z = ElementOf<User[]>; // User

// Get all parameters as a tuple — this is Parameters<T>
type Params<T> = T extends (...args: infer A) => any ? A : never;
type W = Params<(a: string, b: number) => void>; // [string, number]

Recursive conditional types

Conditionals can call themselves. Awaited<T> in the stdlib uses this — it unwraps nested promises until it hits a non-promise.

type DeepAwait<T> = T extends Promise<infer U> ? DeepAwait<U> : T;

type Final = DeepAwait<Promise<Promise<Promise<string>>>>; // string

We keep peeling layers off until the condition fails, then return whatever’s left. TS limits recursion depth to keep the compiler from exploding, but for sane shapes it just works.

Putting it together — a typed event emitter

Here’s something we’d actually write. Given a map of event names to payload types, derive the listener signature for any event.

type Events = {
  login: { userId: string };
  logout: { reason: string };
};

type Listener<E extends keyof Events> = (payload: Events[E]) => void;

type ListenerOf<E> = E extends keyof Events
  ? (payload: Events[E]) => void
  : never;

const onLogin: ListenerOf<"login"> = (p) => console.log(p.userId);
// const bad: ListenerOf<"login"> = (p) => console.log(p.reason); // error

Conditional types + infer together are how every “magic” library type is built. Once these click, the rest of advanced TS feels obvious.


25

Template Literal Types

advanced template-literals string-types advanced-types

In simple language, template literal types are the type-level version of JS template strings. Same backtick + ${} syntax, except instead of building runtime strings we build string literal types. This unlocks crazy stuff like deriving event handler names from event names, validating URL patterns, and so on.

The basics

We can interpolate other types into a string template, and TS produces the union of every possible resulting string.

type Greeting = `Hello, ${string}`;
// matches any string starting with "Hello, "

const ok: Greeting = "Hello, Manish"; // works
// const bad: Greeting = "Hi there";  // error

If we plug a union in, TS distributes across all combinations. That’s where it gets interesting.

type Lang = "en" | "fr" | "de";
type Kind = "title" | "body";

type Key = `${Lang}_${Kind}`;
// "en_title" | "en_body" | "fr_title" | "fr_body" | "de_title" | "de_body"

Six exact strings, generated from two unions. No copy-paste, no mistakes.

Built-in string manipulation utilities

TS ships four intrinsic types for case conversion. They only work on string literal types, not generic string.

type A = Uppercase<"hello">;    // "HELLO"
type B = Lowercase<"WORLD">;    // "world"
type C = Capitalize<"manish">;  // "Manish"
type D = Uncapitalize<"Foo">;   // "foo"

Combine with mapped types and we get key remapping that actually does useful things. Here’s how to build typed event handler props from an event name union.

type Event = "click" | "hover" | "focus";

type EventHandlers = {
  [E in Event as `on${Capitalize<E>}`]: () => void;
};
// { onClick: () => void; onHover: () => void; onFocus: () => void }
"click" | "hover" + on${Capitalize} →
"click" → "onClick"
"hover" → "onHover"

Pattern matching with infer

Combine template literals with conditional types + infer and we can parse strings at the type level. Here’s a type that extracts the route param out of a path string.

type ExtractParam<T> = T extends `/${string}/:${infer Param}` ? Param : never;

type P1 = ExtractParam<"/users/:id">;     // "id"
type P2 = ExtractParam<"/posts/:slug">;   // "slug"
type P3 = ExtractParam<"/about">;         // never

The infer Param slot captures whatever sits after : in the matching path. We just wrote a tiny parser using only types. This is exactly how Express-style route typing works in libraries like hono or tRPC.

Real-world: type-safe API paths

Let’s say our API has paths like /users/:id/posts/:postId. We want a function that requires us to pass exactly those params.

type PathParams<P extends string> =
  P extends `${string}:${infer Param}/${infer Rest}`
    ? { [K in Param | keyof PathParams<`/${Rest}`>]: string }
    : P extends `${string}:${infer Param}`
    ? { [K in Param]: string }
    : {};

function get<P extends string>(path: P, params: PathParams<P>) {
  // ... build URL
}

get("/users/:id", { id: "42" });                    // works
get("/users/:id/posts/:postId", { id: "1", postId: "2" }); // works
// get("/users/:id", { wrong: "x" });               // error

Yeah, that’s recursive template literal parsing. Heavy stuff, but it’s the same pattern used in real libraries we depend on.

Type-safe CSS units

A common practical use — restrict a string to a value with a known suffix.

type Pixels = `${number}px`;
type Percent = `${number}%`;
type CSSLength = Pixels | Percent | "auto";

const w1: CSSLength = "200px";   // works
const w2: CSSLength = "50%";     // works
const w3: CSSLength = "auto";    // works
// const w4: CSSLength = "200";  // error — missing unit

${number} is a special form that matches any numeric literal in a template string. There’s also ${bigint} and ${boolean}.

When to reach for it

Template literal types are addictive — we can model wildly complex string formats with them. But they’re also expensive to evaluate and hard to debug. Reach for them when modeling string formats that already exist in our system (event names, route paths, CSS values). For free-form strings, plain string is still the right call.


26

Utility Types

intermediate utility-types partial pick omit record

TS ships a bunch of built-in generic types that transform other types. Knowing these is non-negotiable — they show up in every real codebase and most interviews. The good news is they’re all just mapped + conditional types underneath, so once we know those, we know how each utility works.

Object-shape transforms

These take an object type and produce a tweaked version.

type User = { id: number; name: string; email: string };

type T1 = Partial<User>;   // { id?: number; name?: string; email?: string }
type T2 = Required<User>;  // strips ? off every property
type T3 = Readonly<User>;  // marks all properties readonly

Partial is the one we use most — perfect for PATCH endpoints and update functions where any subset of fields can be passed.

function updateUser(id: number, changes: Partial<User>) {
  // can pass { name: "new" } or { email: "x", name: "y" } or anything in between
}

Pick & Omit — slice and dice

Pick<T, K> keeps only the listed keys. Omit<T, K> drops them. These are the bread and butter for deriving smaller types from a master shape.

type User = { id: number; name: string; email: string; password: string };

type PublicUser = Omit<User, "password">;       // { id; name; email }
type Credentials = Pick<User, "email" | "password">; // { email; password }

In simple language: Pick is opt-in, Omit is opt-out. Reach for whichever is shorter to write — if we want most of the fields, use Omit; if we want a few, use Pick.

Record — build a uniform map

Record<K, V> builds an object type where every key in K maps to a value of type V. Think Map<K, V> but as a plain object type.

type Role = "admin" | "editor" | "viewer";
type Permissions = Record<Role, string[]>;
// { admin: string[]; editor: string[]; viewer: string[] }

const perms: Permissions = {
  admin: ["read", "write", "delete"],
  editor: ["read", "write"],
  viewer: ["read"],
}; // TS forces us to include all three roles

Union transforms — Exclude, Extract, NonNullable

These operate on union types, not object types. The names are confusing because they’re inverses of each other.

type Status = "loading" | "success" | "error" | null;

type Done = Exclude<Status, "loading">;     // "success" | "error" | null
type Finished = Extract<Status, "success" | "error">; // "success" | "error"
type Defined = NonNullable<Status>;          // "loading" | "success" | "error"

The only difference is direction: Exclude removes, Extract keeps. NonNullable is just Exclude<T, null | undefined> — a shortcut for the most common case.

These pull type info out of function and promise types using infer under the hood.

function createUser(name: string, age: number) {
  return { id: 1, name, age, createdAt: new Date() };
}

type User = ReturnType<typeof createUser>;
// { id: number; name: string; age: number; createdAt: Date }

type Args = Parameters<typeof createUser>;
// [name: string, age: number]

async function loadUser() { return { id: 1, name: "Ada" }; }
type LoadedUser = Awaited<ReturnType<typeof loadUser>>;
// { id: number; name: string } — Awaited unwraps the Promise

This is the killer combo: write a function once, derive its arg and return types as needed. No duplication.

Quick reference table

Utility Input Output
Partial<T>{ a: 1, b: 2 }{ a?: 1, b?: 2 }
Required<T>{ a?: 1 }{ a: 1 }
Readonly<T>{ a: 1 }{ readonly a: 1 }
Pick<T,K>{ a; b; c }, "a"{ a }
Omit<T,K>{ a; b; c }, "a"{ b; c }
Record<K,V>"a"|"b", number{ a: number; b: number }
Exclude<T,U>"a"|"b"|"c", "a""b" | "c"
Extract<T,U>"a"|"b"|"c", "a"|"d""a"
NonNullable<T>string | nullstring
ReturnType<F>() => UserUser
Parameters<F>(a: string) => void[a: string]
Awaited<T>Promise<User>User

Composing them

The real power is stacking utilities. A common pattern: build a “create” type that’s just the user-supplied fields of a record.

type DbUser = { id: number; name: string; email: string; createdAt: Date };

// Drop server-managed fields, then make everything required
type CreateUserInput = Required<Omit<DbUser, "id" | "createdAt">>;
// { name: string; email: string }

Compose these and we can model most real domain shapes without ever writing a duplicate type. That’s the entire point.


27

Type Assertions (as) vs satisfies

intermediate as satisfies assertions type-safety

This is one of those interview questions that catches people out — they look similar but do opposite things. In simple language: as tells the compiler what the type is (and the compiler trusts us), while satisfies checks that the value matches a type (but keeps the original narrow type).

The mental model is: as is a one-way door that drops type info, satisfies is a checkpoint that lets the original type pass through.

as — the type assertion (a.k.a. cast)

as says “trust me, this value is of type X”. TS believes us without verifying — that’s the danger. If we lie, we’ll get a runtime error with no compile-time warning.

const input = document.querySelector("input") as HTMLInputElement;
input.value = "hello"; // works — but if the selector found nothing, this crashes at runtime

The valid uses are narrow: when we have more info than the compiler can know (DOM queries, JSON parsing), and when narrowing between two types that overlap. We should treat every as like a small TODO — it’s a place where type safety stops.

const data = JSON.parse(rawJson) as { id: number; name: string };
// We're claiming JSON.parse returned this shape. If it didn't, TS won't catch it.

A common smell is using as to make TS shut up. Don’t. Fix the real type instead.

satisfies — validate without losing precision

satisfies (TS 4.9+) checks that a value matches a type, but the inferred type stays as-is — it doesn’t get widened. This solves a real problem we hit constantly.

type RouteMap = Record<string, { method: string; handler: string }>;

const routes = {
  home: { method: "GET", handler: "homeHandler" },
  login: { method: "POST", handler: "loginHandler" },
} satisfies RouteMap;

routes.home.method;  // type is "GET", not just string
routes.unknown;      // error — key doesn't exist

If we had annotated const routes: RouteMap = ... instead, routes.home.method would widen to string and routes.unknown would silently be allowed (just undefined). satisfies validates against RouteMap but keeps the literal types and the exact keys.

: Type (annotation)
✓ checks shape
✗ widens literals
✗ allows unknown keys
as Type (assertion)
✗ no check
✗ trusts blindly
✓ forces the type
satisfies Type
✓ checks shape
✓ keeps literals
✓ keeps exact keys

The classic example

Here’s the canonical case where satisfies wins. We have a config of colors, some are RGB tuples, others are hex strings, and we want both: type-checking AND the precise inferred type for each entry.

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

// With annotation — we lose the per-key type
const paletteA: Palette = {
  red: [255, 0, 0],
  green: "#00FF00",
};
paletteA.red.toUpperCase();  // works at compile time but crashes at runtime!
// because TS widened red to "tuple | string"

// With satisfies — TS knows each key's exact type
const paletteB = {
  red: [255, 0, 0],
  green: "#00FF00",
} satisfies Palette;

paletteB.red.map((n) => n * 2);     // works — red is tuple
paletteB.green.toUpperCase();        // works — green is string
// paletteB.red.toUpperCase();       // error — tuple has no toUpperCase

satisfies gives us validation plus the most specific type TS can infer. Two birds, one stone.

When to use which

  • satisfies — almost always the right choice when defining a literal object/config that should match a contract. Default to this.
  • as — only when bridging from unknown-shaped data (JSON, DOM, third-party APIs) where TS truly has no way to know. Treat every as as a risk.
  • : Type annotation — when we want the value’s type to be exactly the annotated type (e.g., function parameter types, public API surfaces where we want to widen on purpose).

The as const cousin

Worth mentioning since interviewers love this — as const is a special assertion that turns a literal into its narrowest possible type (readonly, literal-typed).

const colors = ["red", "green", "blue"] as const;
// readonly ["red", "green", "blue"]

type Color = typeof colors[number]; // "red" | "green" | "blue"

as const + satisfies together is the modern way to define typed constants. We get the narrowest possible types AND validation.

const config = {
  env: "production",
  port: 3000,
} as const satisfies { env: string; port: number };

// config.env is "production", not string

Rule of thumb: reach for satisfies first, fall back to as only when truly necessary.


Tooling & Config

28

Modules (import/export) & Namespaces

beginner modules namespaces import export

In simple language, a module is a file that exports stuff so other files can import it. TypeScript uses the same ES module syntax JavaScript uses — import / export. Namespaces are an older TS-only way to organize code that we should know about but rarely use in new code.

Modules — the standard way

Any file with a top-level import or export is a module. Without one, the file is a “script” and everything declared in it goes into the global scope (which is usually bad).

// math.ts — named exports
export function add(a: number, b: number) {
  return a + b;
}
export const PI = 3.14159;

// default export — one per file
export default class Calculator {
  add(a: number, b: number) { return a + b; }
}
// app.ts — importing
import Calculator, { add, PI } from "./math";
import * as math from "./math";        // namespace import
import { add as plus } from "./math";  // rename

console.log(add(1, 2));
console.log(math.PI);

The only difference between named and default exports: default exports are unnamed (the importer picks the name), named exports require matching the name (unless renamed with as).

Type-only imports

TS adds one thing on top of ES modules — type-only imports. These get erased at compile time and don’t generate any runtime require/import.

import type { User } from "./types";
import { type Config, loadConfig } from "./config"; // mixed

// type-only — totally removed at runtime
function process(user: User) { /* ... */ }

Two reasons to use import type: it makes intent clear, and it avoids accidental circular runtime dependencies. With verbatimModuleSyntax: true in tsconfig, TS enforces type-only imports for type-only usage.

Re-exports

We can forward exports from one module through another. Useful for building a “barrel” file (single entry point for a folder).

// utils/index.ts
export { add, PI } from "./math";
export * from "./strings";              // re-export everything
export { default as Calc } from "./math"; // re-export default as named

Now import { add } from "./utils" works. Be careful — barrel files can hurt tree-shaking if not set up right.

Namespaces — the legacy approach

Before ES modules existed, TS had namespace (originally called “internal modules”). They group related code under a name. We mostly see them in old .d.ts files and library typings.

namespace Geometry {
  export interface Point { x: number; y: number }
  export function distance(a: Point, b: Point) {
    return Math.hypot(a.x - b.x, a.y - b.y);
  }
}

const p: Geometry.Point = { x: 0, y: 0 };
Geometry.distance(p, { x: 3, y: 4 }); // 5

Namespaces can span multiple files (they merge by name), which sounds neat but creates implicit dependencies and breaks tree-shaking. Don’t use them in new code. Use ES modules.

ES Modules (use these)
• explicit imports / exports
• one file = one module
• tree-shakeable
• tooling support everywhere
Namespaces (legacy)
• merge across files
• one global per namespace
• poor tree-shaking
• fine in .d.ts only

CommonJS interop

Node’s classic require / module.exports is CommonJS. When importing a CJS module into a TS ES module project, we hit a snag: default-export interop.

// works when esModuleInterop is true (default in modern setups)
import express from "express";

// without esModuleInterop, we'd need:
import * as express from "express";

esModuleInterop: true in tsconfig makes CJS modules look like they have default exports. Modern setups have this on — it’s only relevant if we’re maintaining an old codebase.

Module resolution gotcha

When TS sees import "./math", how does it find the file? That’s controlled by moduleResolution in tsconfig. For modern Node + bundlers, use "bundler" or "node16". With "node16" we need explicit .js extensions in imports (even though the file is .ts) — confusing but correct, because that’s what ES modules require at runtime.

// with moduleResolution: "node16" + ESM
import { add } from "./math.js"; // .js even though file is math.ts

Stick with "bundler" if a bundler (Vite, esbuild, webpack) handles resolution. Use "node16" for native Node ESM.


29

tsconfig.json Essentials

intermediate tsconfig compiler-options config

tsconfig.json has 100+ options but realistically we tune maybe 10 of them. In simple language, this file tells the TS compiler what flavor of JS to emit, how strict to be, and where to look for files. Get the essentials right and the rest can stay default.

The shape

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "baseUrl": ".",
    "paths": { "@/*": ["src/*"] }
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}

That’s a sensible modern config. Let’s go through what each line actually controls.

target — which JS version to emit

target decides what JS version the compiler outputs. Set it to the lowest version we need to support. Modern projects (Node 18+, evergreen browsers) can safely use ES2022 or higher.

"target": "ES2022"

If we set target: "ES5", TS will down-compile async/await to generator-based polyfills and class to function constructors. Painful and unnecessary in 2025.

module & moduleResolution — the import system

These two work together. module decides the output import style. moduleResolution decides how the compiler finds imported files.

"module": "ESNext",          // emit ES module syntax
"moduleResolution": "bundler" // resolve like Vite/webpack do

Quick guide:

  • App in Vite/webpack/esbuild → module: "ESNext", moduleResolution: "bundler"
  • Native Node ESM → module: "Node16", moduleResolution: "Node16" (requires .js extensions in imports)
  • Old Node + CJS → module: "CommonJS", moduleResolution: "Node"

strict — turn on safety

This is the most important flag. "strict": true enables a bundle of checks that catch real bugs. Always enable it.

"strict": true

Behind the scenes, that bundle includes:

  • noImplicitAny — no untyped parameters
  • strictNullChecks — null and undefined are not assignable to other types
  • strictFunctionTypes — proper variance checking
  • strictBindCallApply — type-check bind/call/apply
  • strictPropertyInitialization — class properties must be assigned in constructor
  • noImplicitThisthis can’t be implicitly any
  • alwaysStrict — emit "use strict"
  • useUnknownInCatchVariablescatch (e) is unknown, not any

Migrating an old codebase? Turn strict on and then disable specific sub-flags one at a time. Better than living with no checks.

Strict family — what each flag catches
noImplicitAny → no untyped params
strictNullChecks → null/undefined safety
strictFunctionTypes → callback variance
strictBindCallApply → typed .bind
strictPropertyInit → class fields init
noImplicitThis → typed this
alwaysStrict → "use strict"
useUnknownInCatch → safe catch

paths & baseUrl — clean imports

Tired of import x from "../../../../utils"? paths lets us define aliases.

"baseUrl": ".",
"paths": {
  "@/*": ["src/*"],
  "@components/*": ["src/components/*"]
}

Now we can write import { Button } from "@components/Button". One important catch: TS only uses paths for type-checking — the runtime still needs a bundler or tsconfig-paths to resolve those aliases. If we’re using Vite/Next/webpack, they have their own config to set up matching aliases.

skipLibCheck — pragmatic shortcut

"skipLibCheck": true

Skips type-checking inside node_modules/@types/*. Almost everyone enables this — it speeds up builds and avoids fights with third-party type errors we can’t fix anyway.

esModuleInterop — CJS / ESM bridge

"esModuleInterop": true

Lets us write import express from "express" instead of import * as express from "express" for CommonJS modules. Modern default — leave it on.

include, exclude, files

Outside compilerOptions, we control which files are part of the project.

"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]

include defaults to everything under the tsconfig’s folder. exclude defaults to node_modules, bower_components, jspm_packages, and the outDir. Use files for an explicit list (rare).

Useful extras worth knowing

A few more flags that pay off in real projects:

"noUncheckedIndexedAccess": true,  // arr[0] becomes T | undefined — huge for safety
"exactOptionalPropertyTypes": true, // { x?: T } can't be { x: undefined }
"noImplicitOverride": true,        // require `override` keyword on subclass methods
"verbatimModuleSyntax": true,      // enforce `import type` for type-only imports
"resolveJsonModule": true,         // import data.json
"isolatedModules": true            // each file compilable alone (needed for esbuild/swc)

noUncheckedIndexedAccess is the underrated banger — it makes array and record access return T | undefined, forcing us to handle the empty case. Catches a class of bugs that strictNullChecks misses.


30

Declaration Files (.d.ts)

intermediate declaration-files ambient types

In simple language, a .d.ts file is type-info-only — no runtime code. It tells TS “this thing exists somewhere out there and here’s its shape”. We use it for three jobs: typing JavaScript libraries that don’t ship their own types, adding globals (like a window.myApp), and teaching TS about non-JS imports (like .svg or .css files).

What’s actually in a .d.ts?

Only type declarations. No expressions, no function bodies, no runtime code. The declare keyword is everywhere because it means “this exists, trust me, I’m just describing it”.

// jquery.d.ts (simplified)
declare function $(selector: string): JQuery;

declare interface JQuery {
  hide(): JQuery;
  show(): JQuery;
  text(value: string): JQuery;
}

After this, TS lets us call $("...").hide() with full types, even though the actual jQuery code is plain JS loaded via a script tag.

Three flavors of .d.ts

There are three different patterns depending on what we’re typing.

1. Ambient declarations (global)

These describe globals that exist without being imported. Use these for browser/Node extensions, env shims, etc.

// globals.d.ts
declare const __APP_VERSION__: string; // injected by Vite/webpack DefinePlugin

declare global {
  interface Window {
    analytics: { track: (event: string) => void };
  }
}

// usage anywhere in the project
console.log(__APP_VERSION__);
window.analytics.track("page_view");

The declare global block is the official way to extend globals from inside a module. Without it, augmenting Window won’t work.

2. Module declarations

Used to type a JS module that doesn’t have its own types, or to declare a “fake” module for non-JS assets.

// types/legacy-lib.d.ts
declare module "legacy-lib" {
  export function doStuff(input: string): number;
  export const VERSION: string;
}

// images.d.ts — for asset imports
declare module "*.svg" {
  const src: string;
  export default src;
}

declare module "*.module.css" {
  const classes: Record<string, string>;
  export default classes;
}

Now import logo from "./logo.svg" gives us a properly-typed string. Without this, TS would scream about an unknown module.

3. Module augmentation

Sometimes a library has types but we want to extend them. Use module augmentation.

// extend express's Request to include a custom property
import "express";

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

// now in our handlers:
app.get("/me", (req, res) => {
  res.json(req.user); // typed as { id: string; role: string } | undefined
});

The import at the top is important — it tells TS we’re augmenting an existing module, not redefining it.

Which pattern do I need?
Global variable / window
declare global { interface Window { ... } }
JS lib without types
declare module "lib-name" { ... }
Non-JS file (.svg, .css)
declare module "*.ext" { ... }
Extend existing lib type
import "lib"; declare module "lib" { ... }
Constant injected by bundler
declare const __NAME__: string

Triple-slash directives

The funky /// comments at the top of some .d.ts files. They’re the old way of expressing dependencies between declaration files. Mostly legacy now, but two are worth knowing.

/// <reference types="node" />
/// <reference path="./other.d.ts" />

reference types pulls in a @types/* package. We rarely write these by hand anymore — modern setups use "types" in tsconfig or just rely on automatic @types/* discovery.

@types/* packages

When a library doesn’t ship its own types, the community publishes them on DefinitelyTyped, distributed via npm as @types/xxx.

npm install --save-dev @types/lodash @types/node

TS auto-discovers these from node_modules/@types. We don’t need to import them — just install and they work. To narrow which @types get auto-loaded, set "types": ["node", "jest"] in tsconfig.

Authoring our own — the d.ts for a library

If we publish a TS library, the compiler emits .d.ts files alongside the .js (when declaration: true is set). Consumers get types for free. The package.json needs a "types" (or "typings") field pointing at the entry .d.ts.

// package.json of a published lib
{
  "main": "dist/index.js",
  "types": "dist/index.d.ts"
}
// tsconfig.json
{
  "compilerOptions": {
    "declaration": true,
    "declarationMap": true,  // source maps for declarations — go-to-def works
    "emitDeclarationOnly": false
  }
}

That’s the whole thing. Declaration files look weirder than they are — it’s really just types in a separate file, with declare sprinkled on top to say “no runtime here”.


Patterns & Best Practices

31

Decorators

advanced decorators metaprogramming classes

In simple language, a decorator is a function that wraps a class, method, accessor, or property to modify its behavior. The syntax is @decorator placed above the target. Think of it like a higher-order function but applied at definition time — like Python decorators if we’ve used those.

There are two decorator systems in TS, and that’s the main source of confusion. The legacy/experimental one (with experimentalDecorators: true) is what frameworks like NestJS and TypeORM still use. The new standard (TC39 stage 3, native in TS 5.0+) is the future.

Why we want them

Decorators shine for cross-cutting concerns — things we want to bolt onto many places without repeating ourselves. Logging, caching, validation, dependency injection.

class UserService {
  @log
  @cache(60)
  async getUser(id: string) {
    return await db.users.find(id);
  }
}

Two annotations, and getUser is now logged on every call and cached for 60 seconds. No wrapper functions, no manual instrumentation.

The new (TC39 stage 3) decorators

This is the version we should learn first — it’s the standard going forward. A decorator is a function that receives the target and a context object, and returns a replacement (or undefined).

function log<T extends (...args: any[]) => any>(
  originalMethod: T,
  context: ClassMethodDecoratorContext
) {
  return function (this: any, ...args: any[]) {
    console.log(`calling ${String(context.name)}`);
    const result = originalMethod.apply(this, args);
    console.log(`returned`, result);
    return result;
  } as T;
}

class Greeter {
  @log
  greet(name: string) {
    return `Hello, ${name}`;
  }
}

new Greeter().greet("Manish");
// calling greet
// returned Hello, Manish

The context object tells us what kind of thing we’re decorating ("method", "class", "field", "getter", "setter", "accessor") and gives us hooks like addInitializer. No more reflection metadata gymnastics.

Where can decorators sit?
@deco class Foo { }
class decorator
@deco method() { }
method decorator
@deco accessor name = "x";
auto-accessor
@deco get prop() { }
getter/setter
@deco field = 1;
field (stage 3 only)

Decorator factories — passing arguments

The bare @log form doesn’t take arguments. To pass options, we write a factory — a function that returns a decorator.

function retry(attempts: number) {
  return function <T extends (...args: any[]) => any>(
    original: T,
    _context: ClassMethodDecoratorContext
  ) {
    return async function (this: any, ...args: any[]) {
      for (let i = 0; i < attempts; i++) {
        try { return await original.apply(this, args); }
        catch (e) { if (i === attempts - 1) throw e; }
      }
    } as T;
  };
}

class API {
  @retry(3)
  async fetch(url: string) {
    return await window.fetch(url);
  }
}

@retry(3) calls the factory with 3, which returns the actual decorator. This is the most common shape in real code.

The legacy (experimental) decorators

Most TS code in the wild — Angular, NestJS, TypeORM, MikroORM — still uses the experimental form. It needs two tsconfig flags.

{
  "experimentalDecorators": true,
  "emitDecoratorMetadata": true
}

The signature is different. A legacy method decorator takes (target, propertyKey, descriptor):

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

class OldGreeter {
  @logLegacy
  greet(name: string) { return `Hi, ${name}`; }
}

emitDecoratorMetadata makes TS emit type info at runtime (via reflect-metadata), which frameworks like NestJS rely on for dependency injection.

Picking which one to use

Right now (TS 5.x), here’s the practical guidance:

  • Greenfield project, no framework requiring legacy → use stage 3 (default, no flag needed)
  • NestJS / Angular / TypeORM → must use legacy + emitDecoratorMetadata
  • Library author → avoid decorators in the public API if possible; if needed, target stage 3

The two are not interoperable in the same file. We pick one for the project and stick with it.

A practical example — dependency injection

Decorators feel abstract until we see a real-world payoff. Here’s a tiny DI container using the stage 3 API.

const services = new Map<string, any>();

function injectable(name: string) {
  return function <T extends new (...args: any[]) => any>(target: T, _ctx: ClassDecoratorContext) {
    services.set(name, new target());
    return target;
  };
}

@injectable("logger")
class Logger {
  info(msg: string) { console.log(`[INFO] ${msg}`); }
}

const log = services.get("logger") as Logger;
log.info("hello");

Decorators do the boring registration work so our classes stay clean. That’s the whole pitch.


32

Common TypeScript Pitfalls & Best Practices

intermediate pitfalls best-practices type-safety

TypeScript is a leaky abstraction over JavaScript, and that creates some weird footguns. Here’s the collection of pitfalls that show up over and over in code reviews — knowing these separates juniors from seniors in interviews.

any vs unknown — the most important habit

Both can hold any value, but they behave opposite. any opts out of type checking entirely; unknown opts in but requires us to narrow before using the value.

function processAny(data: any) {
  data.foo.bar.baz();  // compiles fine, crashes at runtime if data is null
}

function processUnknown(data: unknown) {
  // data.foo;  // error — can't access anything until we narrow
  if (typeof data === "object" && data !== null && "foo" in data) {
    // now TS knows data has foo
  }
}

In simple language: any is “trust me bro”, unknown is “prove it first”. Use unknown for all external data — JSON, API responses, catch (e). Reserve any for genuine escape hatches (and try to never write it).

Exhaustiveness checks with never

When we switch on a union, we want TS to yell at us if we add a new variant and forget a case. The pattern is never in the default branch.

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number };

function area(s: Shape): number {
  switch (s.kind) {
    case "circle": return Math.PI * s.radius ** 2;
    case "square": return s.side ** 2;
    default:
      const _exhaustive: never = s; // error if we add a Shape variant
      return _exhaustive;
  }
}

Add { kind: "triangle"; ... } to Shape and the never assignment instantly fails. The compiler catches the missing case before we ship it.

Structural typing surprises

TS is structural — two types are compatible if they have the same shape, regardless of name. This sounds great until it bites us.

type UserId = string;
type ProductId = string;

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

const productId: ProductId = "abc";
getUser(productId); // compiles fine — both are just strings!

Both UserId and ProductId are aliases for string, so TS sees no difference. Mistakes like passing a product ID where a user ID is expected slide right through. Enter branded types.

Branded (nominal) types

A common workaround for structural mishaps. We “brand” a type with a unique tag that exists only in the type system.

type Brand<T, B> = T & { readonly __brand: B };

type UserId = Brand<string, "UserId">;
type ProductId = Brand<string, "ProductId">;

function asUserId(s: string): UserId { return s as UserId; }
function asProductId(s: string): ProductId { return s as ProductId; }

const u = asUserId("u_123");
const p = asProductId("p_456");

function loadUser(id: UserId) { /* ... */ }
loadUser(u);  // works
// loadUser(p); // error — ProductId not assignable to UserId

At runtime they’re plain strings — zero overhead. At compile time TS treats them as distinct. Use this for IDs, units (Meters vs Feet), validated strings (Email vs string), and other “same shape, different meaning” cases.

Pitfall → Fix
any everywhere
use unknown + narrow
switch missing cases
never check in default
ID type confusion
branded types
arr[0] is T (lies)
noUncheckedIndexedAccess
@ts-ignore everywhere
fix root cause / @ts-expect-error

Array access lies

By default, arr[i] is typed as the element type — but at runtime it can be undefined. This is one of TS’s biggest holes.

const users = ["Ada", "Linus"];
const x = users[5]; // type: string, but actual value: undefined!
x.toUpperCase();    // TS happy, runtime crash

Fix: enable noUncheckedIndexedAccess: true in tsconfig. Now users[5] is typed string | undefined and we’re forced to handle the missing case. Game-changer for safety.

Type predicates — narrowing helpers

When we write a runtime check, TS doesn’t always follow it. Type predicates teach the compiler.

function isString(v: unknown): v is string {
  return typeof v === "string";
}

function process(input: unknown) {
  if (isString(input)) {
    input.toUpperCase(); // narrowed to string
  }
}

The is string return type is the signal. Use these for custom guards (e.g., isUser, isErrorResponse). They’re way better than as casts.

@ts-ignore is a smell

// @ts-ignore silences the next line’s errors. Don’t use it. Two better options:

// @ts-expect-error — fails the build if the error goes away (cleans up itself)
const x: string = 42;

// @ts-expect-error
someMethodWithKnownBadTypes();

@ts-expect-error is self-cleaning. If we fix the underlying issue later, TS warns us that the suppression is now unnecessary. Trust me, future-us will appreciate it.

A few more quick wins

  • Don’t use Function type — too broad. Use a specific signature like (...args: any[]) => any.
  • Don’t use object type — also too broad. Use Record<string, unknown> or a specific shape.
  • Don’t use Number, String, Boolean (capital first letter) — use lowercase number, string, boolean. The capitalized ones refer to wrapper objects, almost never what we want.
  • Prefer interface for objects we’ll extend, type for unions and computed types — both work for most cases, but interface supports declaration merging.

The theme across all of these: TypeScript only helps if we let it. Every any, as, and @ts-ignore is a tiny hole in our safety net. Plug them.