🌙
☀️ Dark
PART 5

TypeScript

Types, generics, narrowing, utility types, strict mode.

Intermediate 45 min read

Part 5: TypeScript

Learning Objectives

By the end of this chapter, you will be able to:

  • Differentiate between runtime and compile-time types and explain how TypeScript operates solely at compile-time.
  • Wield core type structures including primitives, arrays, tuples, interfaces, and type aliases.
  • Leverage TypeScript's structural typing, type inference, unions, and intersections to model complex domains.
  • Use advanced type mechanics: Generics, narrowing, type guards, and discriminated unions.
  • Perform type transformations using utility types, mapped types, and conditional types.
  • Configure tsconfig.json for strict type safety and production compilation.
  • Understand decorators, ambient declaration files (.d.ts), and integrate typed code with untyped ecosystems.

Prerequisites

Solid understanding of JavaScript fundamentals (ES6+), closures, classes, object prototypes, and asynchronous programming (Promises/async-await).

Why Does This Exist?

JavaScript is dynamically typed. Variables can hold any type of data at runtime, and the language will silently perform type coercions or throw TypeError: undefined is not a function when assumptions fail. As applications scale beyond a few thousand lines and multiple engineers, tracking what shape an object should have or what arguments a function accepts becomes a monumental cognitive burden. TypeScript exists to add a static type layer on top of JavaScript, catching errors at compile-time before the code ever runs in production.

The Problem Before the Solution

In large JavaScript codebases, engineers constantly ask: "What does this function return?", "What properties does this user object have?", or "Is this ID a string or a number?". To solve this, teams relied on extensive JSDoc comments, runtime validation libraries, or rigorous (and brittle) unit tests just to ensure basic data shapes were correct.

Why the Old Approach Breaks

Documentation gets outdated. JSDoc comments rot as code evolves. Writing unit tests to verify that a function returns a string instead of a number is a massive waste of engineering time. Runtime validation is necessary at boundaries (like APIs), but using it internally slows down execution and doesn't provide developer tooling (like autocomplete) in the IDE.

History

TypeScript was developed by Microsoft, led by Anders Hejlsberg (creator of C#), and released in 2012. It was designed to address the shortcomings of JavaScript for large-scale application development. Over time, it evolved from being "C# in the browser" to a highly flexible, structural type system that embraces JavaScript's dynamic nature rather than fighting it.

Mental Model (Analogy -> Reality)

Analogy: Imagine JavaScript as a chaotic construction site where workers grab whatever materials they find to build a house. Sometimes they use a wooden beam instead of a steel girder, and the house collapses only when the roof is added (runtime error). TypeScript is the architect's blueprint and the strict site inspector. The inspector ensures the right materials are used before construction even begins (compile-time). Once the inspector approves, they disappear, and the house is built exactly as it would be in JavaScript.

Reality: TypeScript is a superset of JavaScript. You write TypeScript, the compiler (tsc) strips away all the type information, and it emits pure JavaScript. The types do not exist at runtime.

Internal Working (Memory, stack, process, network, etc.)

Runtime vs Compile-time: TypeScript only exists at compile-time. When a Node.js process or a browser executes the emitted JavaScript, there is no type checking, no interfaces, and no generics in memory. This means TypeScript cannot protect you from external data (like a JSON payload from an API) that violates your types at runtime. For that, you need runtime validation tools like Zod or Joi.

Visual Explanation (ASCII diagrams)

[ TypeScript Source (.ts) ]
        |
        | (Type Checking via Compiler) -> Throws errors if types don't align
        v
[ Abstract Syntax Tree ]
        |
        | (Type Erasure) -> Strips interfaces, type aliases, annotations
        v
[ JavaScript Output (.js) ] -> Executed by V8/Node/Browser (No Types!)

Syntax

TypeScript introduces type annotations, interfaces, aliases, generics, and more.

// Type Annotation & Inference
let age: number = 25; // Explicit
let name = "Alice";   // Inferred as string

// Type Alias vs Interface
type UserID = string | number; // Union Type

interface User {
  id: UserID;
  name: string;
  isActive?: boolean; // Optional property
}

Tiny Example

function greet(user: User): string {
  return `Hello, ${user.name}!`;
}

greet({ id: 1, name: "Bob" }); // OK
// greet({ id: 2 }); // Error: Property 'name' is missing

Walkthrough

Let's look at advanced mechanics.

  • Unions and Intersections: Combine types using | (OR) and & (AND).
  • Narrowing and Type Guards: Refining types at runtime. Using typeof, instanceof, or custom type guard functions (arg is Type).
  • Discriminated Unions: Using a common literal property to narrow a union. Highly effective for state management or event handling.
  • Generics: Parameterizing types. <T> acts as a variable for a type.

Break It

function processId(id: string | number) {
  // id.toUpperCase(); // Error: Property 'toUpperCase' does not exist on type 'string | number'.
}

Debug It

To fix the above, we use narrowing (Type Guards):

function processId(id: string | number) {
  if (typeof id === "string") {
    return id.toUpperCase(); // OK: TypeScript knows id is a string here
  }
  return id.toFixed(2); // OK: TypeScript knows id is a number here
}

Mini Project

Create a typed event bus using discriminated unions.

type AppEvent = 
  | { type: 'LOGIN'; payload: { userId: string } }
  | { type: 'LOGOUT' }
  | { type: 'ERROR'; payload: { message: string; code: number } };

function handleEvent(event: AppEvent) {
  switch (event.type) {
    case 'LOGIN':
      console.log(`User logged in: ${event.payload.userId}`);
      break;
    case 'LOGOUT':
      console.log('User logged out');
      break;
    case 'ERROR':
      console.error(`Error ${event.payload.code}: ${event.payload.message}`);
      break;
    default:
      // Exhaustiveness checking
      const _exhaustiveCheck: never = event;
      return _exhaustiveCheck;
  }
}

Real Application Feature

Using Utility Types (Partial, Pick, Omit, Record), Mapped Types, and Conditional Types to transform backend API responses into frontend models.

interface DBUser {
  id: string;
  email: string;
  passwordHash: string;
  createdAt: Date;
}

// Omit sensitive data for the frontend
type PublicUser = Omit<DBUser, 'passwordHash'>;

// Make all fields optional for an update API
type UpdateUserDto = Partial<PublicUser>;

Production Implementation

In production, TypeScript configuration (tsconfig.json) is vital. Strict mode must be enabled to gain the full benefits of the type system.

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "CommonJS",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "outDir": "./dist"
  },
  "include": ["src/**/*"]
}

Production Usage

When interacting with untyped npm packages, you must write Ambient Declaration Files (.d.ts). Decorators are often used in frameworks like NestJS for metadata reflection, though modern TS is moving towards standard ECMAScript decorators.

Performance

TypeScript has zero impact on runtime performance since types are erased. However, compile times can grow in massive monorepos. Using skipLibCheck: true, project references, and bundlers like esbuild or swc (which strip types without checking them) can vastly improve build times.

Best Practices

  • Enable strict: true from day one.
  • Avoid the any type at all costs. Use unknown if you truly don't know the type, forcing you to narrow it later.
  • Prefer Interfaces for object shapes and Type Aliases for unions/intersections.
  • Use discriminated unions to represent application states (e.g., loading, success, error) instead of boolean flags.

Interview Questions

Easy: What is the difference between `interface` and `type`?

Both describe object shapes. Interfaces can be merged (declaration merging) and are generally better for defining public APIs or object shapes. Type aliases can express unions, intersections, and primitives, making them more versatile for complex types.

Medium: What is the difference between `any` and `unknown`?

any turns off type checking entirely, allowing any operation. unknown is a type-safe counterpart; you can assign anything to it, but you cannot perform operations on it without narrowing it first via type guards.

Hard: How does TypeScript's Structural Typing work?

Unlike nominal typing (e.g., Java/C#) where classes must explicitly declare they implement an interface, TypeScript checks shape. If an object has all the required properties of a type, it is considered a valid instance of that type, regardless of how it was created.

Senior: Explain Conditional Types and the `infer` keyword.

Conditional types look like ternaries (T extends U ? X : Y) operating on types. The infer keyword is used within the extends clause to extract a type variable from another type, such as extracting the return type of a function: type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;

Engineering Challenge

Build a deeply nested Partial utility type. TypeScript's built-in Partial<T> only makes top-level properties optional. Write a DeepPartial<T> type that makes all nested object properties optional as well.

View Solution
type DeepPartial<T> = T extends Function
  ? T
  : T extends Array<infer U>
  ? _DeepPartialArray<U>
  : T extends object
  ? _DeepPartialObject<T>
  : T | undefined;

interface _DeepPartialArray<T> extends Array<DeepPartial<T>> {}

type _DeepPartialObject<T> = {
  [P in keyof T]?: DeepPartial<T[P]>;
};

Revision Sheet

  • Structural Typing: If it quacks like a duck, it's a duck.
  • Narrowing: Refining loose types into specific ones using logic (e.g., typeof).
  • Discriminated Unions: The best way to model mutually exclusive states.
  • Compile-time only: Types vanish at runtime. Validate boundaries!

Connections

TypeScript naturally leads into defining API contracts (covered in Backend Engineering) and establishing database schemas (where ORMs like Prisma auto-generate TypeScript types from your schema).

Mini Project (20-30 min)

▶ View Solution

Implementation

javascript
// code here

Bigger Project (1-2 hours)

Build a comprehensive project for this chapter's topic.

▶ View Solution

Full implementation details

typescript
// code here
🏠 Curriculum NextVolume 2