Chapter 82 β€” TypeScript Essentials for MERN

πŸ“– Definition

TypeScript is a statically typed superset of JavaScript that compiles to plain JS. In a MERN app it lets you describe the shape of data once β€” a User, an Order, an API response β€” and have the compiler check every place that data flows: the Mongoose model, the Express handler, the fetch call, and the React component that renders it.

Types exist only at compile time. At runtime it is still JavaScript β€” which is why you also need runtime validation (express-validator or custom type guards β€” see Typed Express Server) at every trust boundary.

πŸ” Why TypeScript in a MERN Stack?

Without TS With TS
user.emial β†’ undefined at runtime Compile error: Property 'emial' does not exist
Backend renames name β†’ fullName, frontend silently breaks Shared type changes β†’ every consumer goes red
"What does this endpoint return?" β†’ read the code Hover the function, see the type
Refactors are scary Rename symbol across client + server safely

Interviewers expect you to know why (safety, refactoring, self-documenting contracts) and the limits (no runtime guarantees, any leaks, type-only imports vanish).

πŸ’» Code Example β€” type vs interface

// interface β€” extendable, can be re-opened (declaration merging)
interface User {
  id: string;
  email: string;
}
interface User {           // merges with the one above
  role: "admin" | "user";
}

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

// type β€” can express unions, tuples, mapped & conditional types
type Role = "admin" | "user";
type ApiResult<T> = { ok: true; data: T } | { ok: false; error: string };
type Point = [x: number, y: number];

Rule of thumb: interface for object shapes you expect to extend (and for augmenting libraries like Express's Request); type for unions, utility compositions and everything else. Both are fine β€” consistency matters more.

πŸ’» Code Example β€” Generics

// A generic paginated response used by every list endpoint
export interface Paginated<T> {
  items: T[];
  page: number;
  limit: number;
  total: number;
}

// Generic constraint: T must have an _id
function indexById<T extends { _id: string }>(list: T[]): Record<string, T> {
  return Object.fromEntries(list.map((item) => [item._id, item]));
}

// Generic with default
type ApiResponse<T = unknown> = { success: boolean; data: T; message?: string };

πŸ’» Code Example β€” Utility Types You Will Actually Use

interface User {
  _id: string;
  email: string;
  name: string;
  password: string;
  role: "admin" | "user";
  createdAt: Date;
}

type PublicUser    = Omit<User, "password">;                  // response DTO
type CreateUserDto = Pick<User, "email" | "name" | "password">;
type UpdateUserDto = Partial<Omit<User, "_id" | "createdAt">>; // PATCH body
type ReadonlyUser  = Readonly<User>;
type RoleCount     = Record<User["role"], number>;            // { admin: number; user: number }

type Fn = (id: string) => Promise<User>;
type FnReturn = Awaited<ReturnType<Fn>>;                      // User
type FnArgs   = Parameters<Fn>;                               // [id: string]

type MaybeNull = string | null | undefined;
type Sure      = NonNullable<MaybeNull>;                      // string

πŸ’» Code Example β€” Narrowing & Discriminated Unions

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

function render(state: FetchState<User[]>): string {
  switch (state.status) {
    case "idle":    return "Nothing yet";
    case "loading": return "Loading…";
    case "success": return `${state.data.length} users`;   // data is available here
    case "error":   return state.error;                    // error is available here
    default: {
      const _exhaustive: never = state;                    // compile error if a case is missed
      return _exhaustive;
    }
  }
}

Other narrowing tools:

if (typeof x === "string") {}            // typeof guard
if (err instanceof AppError) {}          // instanceof guard
if ("data" in res) {}                    // in-operator guard

// Custom type guard
function isUser(v: unknown): v is User {
  return typeof v === "object" && v !== null && "email" in v;
}

// Assertion function
function assertDefined<T>(v: T, msg: string): asserts v is NonNullable<T> {
  if (v == null) throw new Error(msg);
}

πŸ’» Code Example β€” any vs unknown vs never

let a: any = JSON.parse(raw);
a.foo.bar.baz();                 // compiles β€” and crashes at runtime. any turns checking OFF.

let u: unknown = JSON.parse(raw);
// u.foo;                        // ❌ error: must narrow first
if (typeof u === "object" && u !== null && "foo" in u) { /* ok */ }

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

In catch blocks, err is unknown under strict (useUnknownInCatchVariables). Narrow with err instanceof Error before reading .message.

πŸ’» Code Example β€” as const, satisfies, and Enums

// as const β†’ literal, readonly types; great replacement for enums
export const ROLES = ["admin", "user", "guest"] as const;
export type Role = (typeof ROLES)[number];          // "admin" | "user" | "guest"

export const ORDER_STATUS = {
  Pending: "pending",
  Paid: "paid",
  Shipped: "shipped",
} as const;
export type OrderStatus = (typeof ORDER_STATUS)[keyof typeof ORDER_STATUS];

// satisfies β†’ checks the shape WITHOUT widening the inferred type
const routes = {
  home: "/",
  user: "/users/:id",
} satisfies Record<string, string>;
routes.user;   // type is still the literal "/users/:id", and typos in keys are caught

Why many teams avoid enum: numeric enums are not type-safe (Status.A = 99 compiles in older versions), they generate runtime code, and they don't work with erasableSyntaxOnly / Node's native type stripping. as const objects + union types give the same ergonomics with zero runtime cost.

πŸ’» Code Example β€” Mapped & Conditional Types (Senior-level)

// Make every field of T optional AND nullable
type Nullable<T> = { [K in keyof T]?: T[K] | null };

// Pick only the keys of T whose values are strings
type StringKeys<T> = { [K in keyof T]: T[K] extends string ? K : never }[keyof T];
type UserStringFields = StringKeys<User>;  // "_id" | "email" | "name" | "password" | "role"

// Unwrap an array element
type ElementOf<T> = T extends readonly (infer E)[] ? E : never;
type R = ElementOf<typeof ROLES>;          // "admin" | "user" | "guest"

// Template-literal types β€” typed event names
type Entity = "user" | "order";
type EventName = `${Entity}:${"created" | "deleted"}`;  // "user:created" | ...

πŸ’» Code Example β€” A Strict tsconfig.json

jsonc
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",              // server; use "ESNext" + "Bundler" resolution for Vite/React
    "moduleResolution": "NodeNext",
    "strict": true,                    // strictNullChecks, noImplicitAny, etc.
    "noUncheckedIndexedAccess": true,  // arr[0] is T | undefined
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "verbatimModuleSyntax": true,      // forces `import type` for type-only imports
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "dist",
    "rootDir": "src",
    "sourceMap": true
  },
  "include": ["src"]
}

import type { User } from "./types" is erased completely at compile time β€” important for avoiding circular runtime imports and for bundle size.

🌍 Real-World Impact

  • One shared types package means a backend change produces compile errors in the React app before deploy, not bug reports after.
  • Discriminated unions make impossible UI states (loading and error) unrepresentable.
  • strict + noUncheckedIndexedAccess catches the classic Cannot read properties of undefined at build time.

🎯 Likely Interview Questions

  1. type vs interface β€” when do you use each? β€” Interface for extendable object shapes and declaration merging (augmenting Express Request); type for unions, tuples, mapped/conditional types.
  2. any vs unknown? β€” any disables checking; unknown forces you to narrow before use. Prefer unknown for untrusted input.
  3. What is a discriminated union and why use it? β€” A union with a shared literal field (status) so TS narrows by checking it; enables exhaustive switch with never.
  4. Explain Partial, Pick, Omit, Record. β€” Give the DTO examples above.
  5. What does satisfies do differently from a type annotation? β€” Validates against a type but keeps the narrower inferred type.
  6. Does TypeScript validate API input at runtime? β€” No. Types are erased; validate at boundaries with express-validator, class-validator (NestJS) or hand-written type guards.
  7. What is never used for? β€” Unreachable code, functions that throw, and exhaustiveness checks.
  8. Why avoid enum? β€” Runtime output, numeric-enum unsafety, incompatible with type-stripping; use as const unions.

← MongoDB Rapid-Fire Q&A | Index | Next: Project Setup & Architecture β†’