Skip to content
Published on

TypeScript Complete Guide: Type System to Advanced Patterns

Authors

TypeScript Complete Guide: Type System to Advanced Patterns

TypeScript adds a static type system on top of JavaScript. As of 2026, most large-scale projects — both frontend and backend — have adopted TypeScript as their standard. This guide covers everything from the basics to advanced patterns that you can apply directly in production code.


1. TypeScript Type System Fundamentals

1.1 Primitive Types

TypeScript's primitive types mirror JavaScript's but are declared explicitly.

// Basic primitive types
const name: string = 'Alice'
const age: number = 30
const isActive: boolean = true
const nothing: null = null
const undef: undefined = undefined

// ES6+ types
const sym: symbol = Symbol('unique')
const big: bigint = 9007199254740991n

// Type inference - TypeScript infers the type from the assigned value
const inferredName = 'Alice' // inferred as string
const inferredAge = 30 // inferred as number

1.2 Arrays and Tuples

// Two equivalent array type notations
const nums1: number[] = [1, 2, 3]
const nums2: Array<number> = [1, 2, 3]

// Read-only array
const readonlyNums: readonly number[] = [1, 2, 3]
// readonlyNums.push(4)  // Error: push does not exist on readonly array

// Tuple - fixed length and type at each position
const pair: [string, number] = ['Alice', 30]
const triple: [string, number, boolean] = ['Bob', 25, true]

// Optional tuple element
const optional: [string, number?] = ['Charlie']

// Rest tuple
const rest: [string, ...number[]] = ['start', 1, 2, 3, 4]

1.3 Enums

// Numeric enum (default: starts from 0)
enum Direction {
  Up, // 0
  Down, // 1
  Left, // 2
  Right, // 3
}

// String enum
enum Color {
  Red = 'RED',
  Green = 'GREEN',
  Blue = 'BLUE',
}

// const enum - inlined at compile time, reducing bundle size
const enum Status {
  Active = 'ACTIVE',
  Inactive = 'INACTIVE',
  Pending = 'PENDING',
}

// When using const enum, the value is substituted directly
const currentStatus = Status.Active // compiled to: 'ACTIVE'

// When to choose regular enum vs const enum:
// - Need to iterate the enum object at runtime: use regular enum
// - Performance matters and iteration is not needed: use const enum

1.4 any vs unknown vs never vs void

These four special types are the most commonly confused in TypeScript.

// any - completely disables type checking (avoid when possible)
let anyVal: any = 'string'
anyVal = 42 // OK
anyVal = true // OK
anyVal.nonExistent() // possible runtime error, no compile error

// unknown - safer alternative to any
let unknownVal: unknown = 'string'
// unknownVal.toUpperCase()  // Error: needs type narrowing first
if (typeof unknownVal === 'string') {
  unknownVal.toUpperCase() // OK: after type guard
}

// never - type for values that never occur
function throwError(msg: string): never {
  throw new Error(msg)
}

function infiniteLoop(): never {
  while (true) {}
}

// never is useful for exhaustiveness checks
type Shape = 'circle' | 'square' | 'triangle'
function area(shape: Shape): number {
  switch (shape) {
    case 'circle':
      return Math.PI * 1
    case 'square':
      return 1
    case 'triangle':
      return 0.5
    default:
      // shape must be never here - adding a new Shape causes a compile error
      const _exhaustive: never = shape
      throw new Error(`Unknown shape: ${_exhaustive}`)
  }
}

// void - for functions with no return value
function logMessage(msg: string): void {
  console.log(msg)
}

1.5 Type Alias vs Interface

// Type Alias
type Point = {
  x: number
  y: number
}

type ID = string | number

// Interface
interface IPoint {
  x: number
  y: number
}

// Key differences:
// 1. Interface supports Declaration Merging
interface IPoint {
  z?: number // adds z to existing IPoint
}

// 2. Type Alias supports union, intersection, and primitive types
type StringOrNumber = string | number
type WithTimestamp = Point & { createdAt: Date }

// 3. Both can be implemented by classes
class MyPoint implements IPoint {
  x = 0
  y = 0
}

// When to choose:
// - Public APIs for libraries/SDKs: Interface (extensible via declaration merging)
// - Internal app types: Type Alias (more flexible)
// - Object shapes only: either works fine

1.6 Literal Types and Template Literal Types

// Literal types
type Direction = 'north' | 'south' | 'east' | 'west'
type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6

const move = (dir: Direction) => console.log(dir)
move('north') // OK
// move('up')  // Error

// Template literal types (TypeScript 4.1+)
type EventName = 'click' | 'focus' | 'blur'
type Handler = `on${Capitalize<EventName>}`
// Result: 'onClick' | 'onFocus' | 'onBlur'

type CSSUnit = 'px' | 'em' | 'rem' | 'vw' | 'vh'
type CSSValue = `${number}${CSSUnit}`
// Examples: '16px', '1.5em', '100vw', etc.

// Practical example: API route types
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
type Endpoint = '/users' | '/posts' | '/comments'
type APIRoute = `${HttpMethod} ${Endpoint}`
// Result: 12 combinations like 'GET /users', 'POST /posts', etc.

2. Object Types and Utility Types

2.1 Advanced Object Type Features

interface User {
  readonly id: number // read-only
  name: string
  email?: string // optional property
  [key: string]: unknown // index signature
}

const user: User = { id: 1, name: 'Alice' }
// user.id = 2  // Error: readonly

2.2 Built-in Utility Types

interface Todo {
  id: number
  title: string
  completed: boolean
  description: string
}

// Partial<T> - makes all properties optional
type PartialTodo = Partial<Todo>

// Required<T> - makes all properties required
type RequiredTodo = Required<Partial<Todo>>

// Readonly<T> - makes all properties read-only
type ReadonlyTodo = Readonly<Todo>

// Pick<T, K> - picks specific properties
type TodoPreview = Pick<Todo, 'id' | 'title'>

// Omit<T, K> - omits specific properties
type TodoWithoutDescription = Omit<Todo, 'description'>

// Record<K, V> - creates a key-value map type
type TodoMap = Record<number, Todo>

// Exclude<T, U> - removes U from the union T
type T1 = Exclude<string | number | boolean, boolean> // string | number

// Extract<T, U> - keeps only U from the union T
type T2 = Extract<string | number | boolean, string | boolean> // string | boolean

// NonNullable<T> - removes null and undefined
type T3 = NonNullable<string | null | undefined> // string

// ReturnType<T> - extracts the return type of a function
function getUser() {
  return { id: 1, name: 'Alice' }
}
type UserReturn = ReturnType<typeof getUser> // { id: number; name: string }

// Parameters<T> - extracts parameter types as a tuple
function createUser(name: string, age: number, active: boolean) {}
type CreateUserParams = Parameters<typeof createUser> // [string, number, boolean]

// ConstructorParameters<T> - extracts constructor parameter types
class ApiClient {
  constructor(
    public baseUrl: string,
    public timeout: number
  ) {}
}
type ClientParams = ConstructorParameters<typeof ApiClient> // [string, number]

2.3 Mapped Types

// Basic mapped type
type Flags<T> = {
  [K in keyof T]: boolean
}

interface Config {
  darkMode: string
  notifications: string
  analytics: string
}

type ConfigFlags = Flags<Config>
// { darkMode: boolean; notifications: boolean; analytics: boolean }

// Adding/removing modifiers
type Mutable<T> = {
  -readonly [K in keyof T]: T[K] // removes readonly
}

type AllRequired<T> = {
  [K in keyof T]-?: T[K] // removes optionality
}

// Key remapping (TypeScript 4.1+)
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}

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

// Key remapping for filtering
type OnlyStrings<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K]
}

2.4 Conditional Types

// Basic conditional type
type IsString<T> = T extends string ? true : false
type A = IsString<string> // true
type B = IsString<number> // false

// Distributive conditional types (distributed over unions)
type ToArray<T> = T extends any ? T[] : never
type C = ToArray<string | number> // string[] | number[]

// The infer keyword - infers a type within a conditional type
type UnpackArray<T> = T extends Array<infer Item> ? Item : T
type D = UnpackArray<string[]> // string
type E = UnpackArray<number> // number (not an array, returned as-is)

type UnpackPromise<T> = T extends Promise<infer R> ? R : T
type F = UnpackPromise<Promise<string>> // string

// Implementing ReturnType manually
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never
type G = MyReturnType<() => string> // string

// Deep type manipulation
type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T

3. Generics

3.1 Generic Functions

// Basic generic function
function identity<T>(value: T): T {
  return value
}

const str = identity<string>('hello') // string
const num = identity(42) // number (inferred)

// Multiple type parameters
function pair<A, B>(first: A, second: B): [A, B] {
  return [first, second]
}

// Return the first element of an array
function first<T>(arr: T[]): T | undefined {
  return arr[0]
}

3.2 Generic Interfaces and Classes

// Generic interface
interface Repository<T> {
  findById(id: number): Promise<T | null>
  findAll(): Promise<T[]>
  create(item: Omit<T, 'id'>): Promise<T>
  update(id: number, item: Partial<T>): Promise<T>
  delete(id: number): Promise<void>
}

// Generic class
class Stack<T> {
  private items: T[] = []

  push(item: T): void {
    this.items.push(item)
  }

  pop(): T | undefined {
    return this.items.pop()
  }

  peek(): T | undefined {
    return this.items[this.items.length - 1]
  }

  get size(): number {
    return this.items.length
  }

  isEmpty(): boolean {
    return this.items.length === 0
  }
}

const stack = new Stack<number>()
stack.push(1)
stack.push(2)
console.log(stack.pop()) // 2

3.3 Generic Constraints (extends)

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

const user = { name: 'Alice', age: 30 }
const name = getProperty(user, 'name') // string
// getProperty(user, 'email')  // Error: 'email' is not keyof typeof user

// Interface constraint
interface HasId {
  id: number
}

function findById<T extends HasId>(items: T[], id: number): T | undefined {
  return items.find((item) => item.id === id)
}

// Multiple constraints
interface Serializable {
  serialize(): string
}

function processAndSerialize<T extends HasId & Serializable>(item: T): string {
  return `${item.id}: ${item.serialize()}`
}

3.4 Practical Generic Patterns

// API Response type pattern
interface ApiResponse<T> {
  data: T
  status: number
  message: string
  timestamp: string
}

interface PaginatedResponse<T> extends ApiResponse<T[]> {
  pagination: {
    page: number
    limit: number
    total: number
    totalPages: number
  }
}

// Repository pattern implementation
class UserRepository implements Repository<User> {
  private baseUrl = '/api/users'

  async findById(id: number): Promise<User | null> {
    const res = await fetch(`${this.baseUrl}/${id}`)
    if (!res.ok) return null
    return res.json()
  }

  async findAll(): Promise<User[]> {
    const res = await fetch(this.baseUrl)
    return res.json()
  }

  async create(item: Omit<User, 'id'>): Promise<User> {
    const res = await fetch(this.baseUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(item),
    })
    return res.json()
  }

  async update(id: number, item: Partial<User>): Promise<User> {
    const res = await fetch(`${this.baseUrl}/${id}`, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(item),
    })
    return res.json()
  }

  async delete(id: number): Promise<void> {
    await fetch(`${this.baseUrl}/${id}`, { method: 'DELETE' })
  }
}

4. Advanced Type Techniques

4.1 Union Types and Intersection Types

// Union Types
type StringOrNumber = string | number
type Nullable<T> = T | null
type Optional<T> = T | null | undefined

// Intersection Types
interface Named {
  name: string
}

interface Aged {
  age: number
}

type Person = Named & Aged
const person: Person = { name: 'Alice', age: 30 }

// Useful intersection pattern
type WithTimestamp<T> = T & {
  createdAt: Date
  updatedAt: Date
}

4.2 Discriminated Unions (Tagged Union / Algebraic Data Types)

// Each case has a unique literal type discriminant
interface LoadingState {
  status: 'loading'
}

interface SuccessState<T> {
  status: 'success'
  data: T
}

interface ErrorState {
  status: 'error'
  error: Error
}

type AsyncState<T> = LoadingState | SuccessState<T> | ErrorState

// Type narrowing via discriminant
function renderState<T>(state: AsyncState<T>): string {
  switch (state.status) {
    case 'loading':
      return 'Loading...'
    case 'success':
      return `Data: ${JSON.stringify(state.data)}`
    case 'error':
      return `Error: ${state.error.message}`
  }
}

// Redux action pattern
type Action =
  | { type: 'INCREMENT'; payload: number }
  | { type: 'DECREMENT'; payload: number }
  | { type: 'RESET' }

function reducer(state: number, action: Action): number {
  switch (action.type) {
    case 'INCREMENT':
      return state + action.payload
    case 'DECREMENT':
      return state - action.payload
    case 'RESET':
      return 0
  }
}

4.3 Type Guards

// typeof type guard
function processValue(value: string | number): string {
  if (typeof value === 'string') {
    return value.toUpperCase() // value: string
  }
  return value.toFixed(2) // value: number
}

// instanceof type guard
class Cat {
  meow() {
    return 'Meow'
  }
}

class Dog {
  bark() {
    return 'Woof'
  }
}

function speak(animal: Cat | Dog): string {
  if (animal instanceof Cat) {
    return animal.meow() // animal: Cat
  }
  return animal.bark() // animal: Dog
}

// in operator type guard
interface Fish {
  swim(): void
}

interface Bird {
  fly(): void
}

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

// Custom type guard (is keyword)
function isString(value: unknown): value is string {
  return typeof value === 'string'
}

function isUser(obj: unknown): obj is User {
  return typeof obj === 'object' && obj !== null && 'id' in obj && 'name' in obj
}

// Assertion Function (TypeScript 3.7+)
function assertIsString(value: unknown): asserts value is string {
  if (typeof value !== 'string') {
    throw new Error(`Expected string, got ${typeof value}`)
  }
}

const maybeString: unknown = 'hello'
assertIsString(maybeString)
console.log(maybeString.toUpperCase()) // narrowed to string after assertion

4.4 Overload Signatures

// Function overloads
function process(x: number): number
function process(x: string): string
function process(x: number[]): number[]
function process(x: number | string | number[]): number | string | number[] {
  if (typeof x === 'number') return x * 2
  if (typeof x === 'string') return x.toUpperCase()
  return x.map((n) => n * 2)
}

const r1 = process(10) // number
const r2 = process('hello') // string
const r3 = process([1, 2, 3]) // number[]

// Method overloads
class EventEmitter {
  on(event: 'data', listener: (data: Buffer) => void): this
  on(event: 'end', listener: () => void): this
  on(event: 'error', listener: (err: Error) => void): this
  on(event: string, listener: (...args: any[]) => void): this {
    // implementation
    return this
  }
}

5. Classes and Decorators

5.1 Access Modifiers and Class Features

class BankAccount {
  readonly accountNumber: string
  private _balance: number
  protected owner: string

  constructor(accountNumber: string, owner: string, initialBalance = 0) {
    this.accountNumber = accountNumber
    this.owner = owner
    this._balance = initialBalance
  }

  get balance(): number {
    return this._balance
  }

  private validateAmount(amount: number): void {
    if (amount <= 0) throw new Error('Amount must be greater than 0')
  }

  deposit(amount: number): void {
    this.validateAmount(amount)
    this._balance += amount
  }

  withdraw(amount: number): void {
    this.validateAmount(amount)
    if (amount > this._balance) throw new Error('Insufficient funds')
    this._balance -= amount
  }

  static createSavingsAccount(owner: string): BankAccount {
    const accountNumber = `SAV-${Date.now()}`
    return new BankAccount(accountNumber, owner, 0)
  }
}

// Constructor parameter shorthand (Parameter Properties)
class Point {
  constructor(
    public readonly x: number,
    public readonly y: number
  ) {}

  distance(other: Point): number {
    return Math.sqrt((this.x - other.x) ** 2 + (this.y - other.y) ** 2)
  }
}

5.2 Abstract Classes

abstract class Shape {
  abstract area(): number
  abstract perimeter(): number

  // Shared implementation
  toString(): string {
    return `Area: ${this.area().toFixed(2)}, Perimeter: ${this.perimeter().toFixed(2)}`
  }
}

class Circle extends Shape {
  constructor(private radius: number) {
    super()
  }

  area(): number {
    return Math.PI * this.radius ** 2
  }

  perimeter(): number {
    return 2 * Math.PI * this.radius
  }
}

class Rectangle extends Shape {
  constructor(
    private width: number,
    private height: number
  ) {
    super()
  }

  area(): number {
    return this.width * this.height
  }

  perimeter(): number {
    return 2 * (this.width + this.height)
  }
}

5.3 Decorators

Decorators require experimentalDecorators: true in tsconfig.json.

// Class decorator
function Singleton<T extends new (...args: any[]) => {}>(constructor: T) {
  let instance: InstanceType<T>
  return class extends constructor {
    constructor(...args: any[]) {
      if (instance) return instance
      super(...args)
      instance = this as any
    }
  }
}

@Singleton
class AppConfig {
  readonly version = '1.0.0'
}

// Method decorator
function Log(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value
  descriptor.value = function (...args: any[]) {
    console.log(`Calling: ${propertyKey}(${JSON.stringify(args)})`)
    const result = original.apply(this, args)
    console.log(`Returned: ${JSON.stringify(result)}`)
    return result
  }
  return descriptor
}

// Property decorator
function Validate(min: number, max: number) {
  return function (target: any, propertyKey: string) {
    let value: number
    Object.defineProperty(target, propertyKey, {
      get: () => value,
      set: (newValue: number) => {
        if (newValue < min || newValue > max) {
          throw new Error(`${propertyKey} must be between ${min} and ${max}`)
        }
        value = newValue
      },
    })
  }
}

class Product {
  name: string

  @Validate(0, 1000000)
  price: number

  constructor(name: string, price: number) {
    this.name = name
    this.price = price
  }

  @Log
  getInfo(): string {
    return `${this.name}: $${this.price}`
  }
}

6. Module System and Declaration Files

6.1 ES Modules in TypeScript

// Named exports
export interface User {
  id: number
  name: string
}

export function createUser(name: string): User {
  return { id: Date.now(), name }
}

// Default export
export default class UserService {
  private users: User[] = []

  add(user: User): void {
    this.users.push(user)
  }
}

// Re-exports
export { User as UserType } from './types'
export * from './utils'
export * as helpers from './helpers'

// Imports
import UserService, { User, createUser } from './user'
import type { User as UserType } from './user' // type-only import (removed at runtime)

6.2 Declaration Files (.d.ts)

// globals.d.ts - global type declarations
declare global {
  interface Window {
    analytics: {
      track(event: string, properties?: Record<string, unknown>): void
    }
  }
}

// Module declaration (augmenting untyped JS libraries)
declare module 'legacy-lib' {
  export function doSomething(value: string): number
  export const VERSION: string
}

// File type declarations (e.g., SVG files)
declare module '*.svg' {
  const content: string
  export default content
}

declare module '*.png' {
  const content: string
  export default content
}

6.3 Key tsconfig.json Options

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ES2022", "DOM"],
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "paths": {
      "@/*": ["./src/*"],
      "@components/*": ["./src/components/*"]
    },
    "baseUrl": ".",
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true
  }
}

7. Practical Patterns

7.1 Builder Pattern with Method Chaining

class QueryBuilder<T> {
  private conditions: string[] = []
  private orderByClause?: string
  private limitValue?: number
  private offsetValue?: number

  where(condition: string): this {
    this.conditions.push(condition)
    return this
  }

  orderBy(field: keyof T & string, direction: 'ASC' | 'DESC' = 'ASC'): this {
    this.orderByClause = `${field} ${direction}`
    return this
  }

  limit(n: number): this {
    this.limitValue = n
    return this
  }

  offset(n: number): this {
    this.offsetValue = n
    return this
  }

  build(): string {
    let query = 'SELECT * FROM table'
    if (this.conditions.length > 0) {
      query += ` WHERE ${this.conditions.join(' AND ')}`
    }
    if (this.orderByClause) {
      query += ` ORDER BY ${this.orderByClause}`
    }
    if (this.limitValue !== undefined) {
      query += ` LIMIT ${this.limitValue}`
    }
    if (this.offsetValue !== undefined) {
      query += ` OFFSET ${this.offsetValue}`
    }
    return query
  }
}

// Usage example
const query = new QueryBuilder<User>()
  .where('age > 18')
  .where('active = true')
  .orderBy('name')
  .limit(10)
  .offset(0)
  .build()

7.2 Result Type (Either Monad Pattern)

type Ok<T> = { success: true; data: T }
type Err<E> = { success: false; error: E }
type Result<T, E = Error> = Ok<T> | Err<E>

function ok<T>(data: T): Ok<T> {
  return { success: true, data }
}

function err<E>(error: E): Err<E> {
  return { success: false, error }
}

// Usage example
async function fetchUser(id: number): Promise<Result<User>> {
  try {
    const response = await fetch(`/api/users/${id}`)
    if (!response.ok) {
      return err(new Error(`HTTP ${response.status}`))
    }
    const data = await response.json()
    return ok(data)
  } catch (e) {
    return err(e instanceof Error ? e : new Error('Unknown error'))
  }
}

// Caller is forced to handle errors explicitly
const result = await fetchUser(1)
if (result.success) {
  console.log(result.data.name)
} else {
  console.error(result.error.message)
}

7.3 Branded Types (Type-Safe IDs)

// Branded types - same underlying type but mutually incompatible
type UserId = number & { readonly _brand: 'UserId' }
type PostId = number & { readonly _brand: 'PostId' }
type CommentId = number & { readonly _brand: 'CommentId' }

// Factory functions
function asUserId(id: number): UserId {
  return id as UserId
}
function asPostId(id: number): PostId {
  return id as PostId
}

function getUser(id: UserId): User {
  /* ... */ return {} as User
}
function getPost(id: PostId): void {
  /* ... */
}

const userId = asUserId(1)
const postId = asPostId(1)

getUser(userId) // OK
// getUser(postId)  // Error: PostId is not assignable to UserId
// getUser(1)       // Error: number is not assignable to UserId

7.4 The satisfies Operator (TypeScript 4.9+)

// satisfies - validates the type while preserving the inferred type
const palette = {
  red: [255, 0, 0],
  green: '#00ff00',
  blue: [0, 0, 255],
} satisfies Record<string, string | number[]>

// Because of satisfies:
palette.red.map((x) => x * 2) // OK: inferred as number[]
palette.green.toUpperCase() // OK: inferred as string

// No type assertion needed while still enforcing the contract
const config = {
  port: 3000,
  host: 'localhost',
  features: {
    auth: true,
    logging: false,
  },
} satisfies {
  port: number
  host: string
  features: Record<string, boolean>
}

8. Quiz

Quiz 1: unknown vs any

Identify which line causes a compile error in the following code.

const a: any = 'hello'
const b: unknown = 'world'

console.log(a.toUpperCase()) // (1)
console.log(b.toUpperCase()) // (2)

if (typeof b === 'string') {
  console.log(b.toUpperCase()) // (3)
}

Answer: Line (2)

Explanation: The unknown type cannot be accessed without a type guard. any disables type checking entirely, so (1) compiles fine. unknown requires narrowing before use, so (2) is a compile error. (3) is fine because the typeof guard has already narrowed the type.

Quiz 2: Composing Utility Types

Express the following type using utility types.

interface Article {
  id: number
  title: string
  content: string
  author: string
  publishedAt: Date
  updatedAt: Date
}

// Goal: make all fields optional except id, publishedAt, and updatedAt
type ArticleUpdateInput = /* ??? */

Answer: Partial<Omit<Article, 'id' | 'publishedAt' | 'updatedAt'>>

Explanation: First use Omit to remove the non-editable fields, then use Partial to make all remaining fields optional. Utility types can be composed this way.

Quiz 3: Generic Constraints

Add generic constraints so the following function compiles without errors.

function mergeObjects<T, U>(target: T, source: U): T & U {
  return { ...target, ...source }
}

// Requirement: both T and U must be object types

Answer: function mergeObjects<T extends object, U extends object>(target: T, source: U): T & U

Explanation: Using extends object prevents passing primitive types (string, number, etc.) and only allows object types. Using Record<string, unknown> is sometimes more explicit.

Quiz 4: Discriminated Union Exhaustiveness

Explain why adding a new shape Triangle to the union does not cause a compile error below, and how to fix it so it does.

type Shape = Circle | Square // After adding Triangle: Circle | Square | Triangle

function getArea(shape: Shape): number {
  if (shape.kind === 'circle') return Math.PI * shape.radius ** 2
  if (shape.kind === 'square') return shape.side ** 2
  return 0 // This prevents the compile error
}

Answer: Replace return 0 with the never exhaustiveness check pattern: const _: never = shape; throw new Error(...)

Explanation: return 0 silently handles all unmatched cases. Using the never exhaustiveness check makes the compiler complain when Triangle is added, because shape would no longer be never at that point.

Quiz 5: infer and Conditional Types

Predict the result of the following types.

type Flatten<T> = T extends Array<infer Item> ? Item : T

type A = Flatten<string[]> // ?
type B = Flatten<number[][]> // ?
type C = Flatten<string> // ?
type D = Flatten<string | number[]> // ?

Answer: A = string, B = number[], C = string, D = string | number

Explanation: Flatten unwraps only one level of array. B sees number[][] as Array<number[]> and returns number[]. D distributes over the union: Flatten<string> | Flatten<number[]> = string | number.


Summary

TypeScript's type system is not mere syntax sugar. When used correctly, it catches runtime errors at compile time and unlocks full IDE autocompletion and refactoring support.

Key takeaways:

  • Prefer unknown over any to maintain type safety
  • Use utility types and mapped types for DRY type definitions
  • Model state machines safely with discriminated unions
  • Write reusable, type-safe code with generics
  • Use the satisfies operator to validate and infer types simultaneously