Lesson overview · Free interview practice

Design Patterns

Master advanced JavaScript design patterns.

Topics in the full lesson
  • Introduction to Design Patterns
  • The Module Pattern
  • The Revealing Module Pattern
  • Encapsulation and Scope
  • Best Practices
  • Exercises

Practice Problems

Work through a question before revealing its explanation. These questions and answers are free; Premium adds the full lesson walkthrough, examples and implementation detail.

Completion marks record your own progress, not an automatically checked result. The task type does not determine whether it is optional.

TheoryEasy

The Module Pattern and Its Revealing Variant

Question

What is the Module Pattern, why did it exist, and how does the Revealing Module Pattern differ? What replaced it?

Take a moment to think about this before revealing the answer

Explain your reasoning or try an implementation before comparing answers.

TheoryEasy

Strategy Pattern

Question

What is the Strategy pattern? Show how JavaScript's first-class functions make it lighter than the textbook OO version.

Take a moment to think about this before revealing the answer

Explain your reasoning or try an implementation before comparing answers.

TheoryEasy

Singleton Pattern

Question

What is the Singleton pattern? Show three idiomatic ways to implement one in JavaScript and explain when each fits.

Take a moment to think about this before revealing the answer

Explain your reasoning or try an implementation before comparing answers.

CodingMedium

Factory Pattern

Implement and check your solution in the stated environment before revealing the answer.

Question

Implement a factory function and a named factory method. Then explain when a factory makes the caller's code clearer than a direct constructor call.

Use one small user-account domain for both implementations. The task does not require an HTTP client or any network behavior.

Contract

  1. createUser({ name, role }) returns a new plain object with exactly name, role, and permissions as its own data fields.
  2. User.forRole({ name, role }) is a static factory method that returns a new User instance with the same three fields. The starter provides the data-storing constructor. You may reuse the function's creation logic.
  3. The options argument is a trusted object. name must be a string, including the empty string. Preserve it exactly. role must be one of the following strings:
RolePermissions, in order
'admin'['read', 'write', 'delete']
'editor'['read', 'write']
'viewer'['read']
  1. Throw TypeError for a non-string name or an unsupported or missing role. Do not trim, normalize, or supply defaults. Error wording is your choice. Ignore extra options.
  2. Every factory call creates a fresh object and a fresh permissions array. Mutating one result must not change another result or the input object.

Here, factory method means a named static construction method, as in the lesson's construction examples. It does not require the inheritance-based GoF Factory Method pattern. Implement both requested entry points, not just an explanation.

The host's factory bridge introduces functions returning objects and static construction methods. Review that bridge and class constructors before this core practice.

Local setup

In a new local folder, save starter.mjs and checks.mjs. Run with Node.js 24.21.0. No browser, packages, or network are needed.

node --version
cp starter.mjs solution.mjs
node checks.mjs

The starter fails until you implement both factories in solution.mjs.

For TypeScript, save the alternative as starter.mts and this configuration as tsconfig.json. The strict compiler path uses TypeScript 5.9.3.

{
  "compilerOptions": {
    "strict": true,
    "target": "ES2022",
    "lib": ["ES2022"],
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "types": [],
    "noEmitOnError": true,
    "outDir": "build"
  },
  "files": ["solution.mts"]
}
tsc --version
cp starter.mts solution.mts
tsc --project tsconfig.json
node checks.mjs ./build/solution.mjs

Use the course's environment setup guidance if a tool is unavailable. The explicit module extensions avoid a package manifest.

Starters

JavaScript starter: starter.mjs

export function createUser({ name, role }) {
  throw new Error('Implement createUser')
}

export class User {
  constructor({ name, role, permissions }) {
    this.name = name
    this.role = role
    this.permissions = permissions
  }

  static forRole(options) {
    throw new Error('Implement User.forRole')
  }
}

TypeScript starter: starter.mts

type Role = 'admin' | 'editor' | 'viewer'
type UserOptions = { name: string, role: string }
type UserData = { name: string, role: Role, permissions: string[] }

export function createUser({ name, role }: UserOptions): UserData {
  throw new Error('Implement createUser')
}

export class User {
  name: string
  role: Role
  permissions: string[]

  constructor({ name, role, permissions }: UserData) {
    this.name = name
    this.role = role
    this.permissions = permissions
  }

  static forRole(options: UserOptions): User {
    throw new Error('Implement User.forRole')
  }
}

Visible checks

The runtime checks also pass invalid values that typed callers would normally reject. Keep the constructor provided in the starter. The public factories are the validation boundary for this task.

Save these checks as checks.mjs.

import assert from 'node:assert/strict'
import test from 'node:test'

const { createUser, User } = await import(process.argv[2] ?? './solution.mjs')

const factories = [
  ['function', options => createUser(options)],
  ['static method', options => User.forRole(options)],
]

for (const [label, make] of factories) {
  test(`normal: ${label} assigns each role's exact permissions`, () => {
    for (const [role, permissions] of [
      ['admin', ['read', 'write', 'delete']],
      ['editor', ['read', 'write']],
      ['viewer', ['read']],
    ]) {
      const result = make({ name: 'Alice', role })
      assert.deepEqual(
        { name: result.name, role: result.role, permissions: result.permissions },
        { name: 'Alice', role, permissions },
      )
      assert.deepEqual(Reflect.ownKeys(result).sort(), ['name', 'permissions', 'role'])
      assert.equal(
        Object.getPrototypeOf(result),
        label === 'function' ? Object.prototype : User.prototype,
      )
    }
  })

  test(`edge: ${label} preserves names and creates independent mutable results`, () => {
    const options = Object.freeze({ name: '', role: 'viewer', ignored: true })
    const first = make(options)
    const second = make(options)
    assert.equal(first.name, '')
    assert.equal(make({ name: ' Alice ', role: 'editor' }).name, ' Alice ')
    assert.notEqual(first, second)
    assert.notEqual(first.permissions, second.permissions)
    first.permissions.push('local-only')
    first.name = 'changed'
    assert.deepEqual(second.permissions, ['read'])
    assert.equal(second.name, '')
    assert.deepEqual(options, { name: '', role: 'viewer', ignored: true })
  })

  test(`error: ${label} rejects invalid fields without defaults`, () => {
    for (const role of ['owner', 'Admin', '', undefined, null, 1]) {
      assert.throws(() => make({ name: 'Alice', role }), TypeError)
    }
    for (const name of [undefined, null, 1, {}, false]) {
      assert.throws(() => make({ name, role: 'viewer' }), TypeError)
    }
    assert.throws(() => make({ name: 'Alice' }), TypeError)
  })
}

test('edge: the two factory forms do not share permission arrays', () => {
  const plain = createUser({ name: 'Alice', role: 'admin' })
  const instance = User.forRole({ name: 'Alice', role: 'admin' })
  assert.equal(plain instanceof User, false)
  assert.equal(instance instanceof User, true)
  assert.notEqual(plain.permissions, instance.permissions)
  plain.permissions.pop()
  assert.deepEqual(instance.permissions, ['read', 'write', 'delete'])
})

Take a moment to think about this before revealing the answer

Explain your reasoning or try an implementation before comparing answers.

CodingHard

Observer (Pub/Sub) Pattern: Implement `EventEmitter`

Implement and check your solution in the stated environment before revealing the answer.

Question

Implement a small synchronous EventEmitter with on, off, emit, and once. Explain how it separates the code publishing an event from the code reacting to it.

Contract

Event names are strings or symbols. Listeners are synchronous functions. Arguments meet these types, so argument validation is not the task.

  • on(event, handler) adds a registration and returns an idempotent disposer for that registration.
  • Duplicate registrations are independent. Registering the same function twice makes it run twice. Disposing one registration leaves the other active.
  • once(event, handler) adds an independent one-shot registration and returns its disposer.
  • off(event, handler) removes all registrations for that exact event and original function, including one-shot registrations. Missing registrations are harmless.
  • emit(event, ...args) calls active listeners synchronously in registration order with the same arguments and object identities. Invoke listeners as plain function calls, not as methods with the emitter as this.
  • Take a snapshot of registrations at the start of each emission. Additions wait until a later emission. A registration removed before its turn is skipped, even if it was in the snapshot.
  • Remove a one-shot registration before invoking it. Nested emissions use the registrations active when that nested emission begins. A one-shot registration must not run again through either a nested emission or an older snapshot.
  • Propagate the first listener error unchanged and stop that emission. A throwing one-shot registration is still consumed. There is no console logging, error aggregation, special 'error' event, or asynchronous scheduling.
  • off and emit return undefined. An event with no active listeners does nothing. A disposer remains harmless after off, after firing once, or after new registrations are added.

This exercise has its own explicit policy. It is not a clone of Node's EventEmitter or DOM EventTarget.

Read the host's Observer bridge before attempting this advanced extension. You will combine closures, Map, Set, snapshot iteration, and cleanup across reentrant calls.

Local setup

Use a new local folder with Node.js 24.21.0. Save starter.mjs and checks.mjs.

node --version
cp starter.mjs solution.mjs
node checks.mjs

The starter is intentionally unfinished. Implement it in solution.mjs. The checks use no packages, browser, timers, or network.

For TypeScript, save starter.mts and this tsconfig.json. Use TypeScript 5.9.3 with strict checking.

{
  "compilerOptions": {
    "strict": true,
    "target": "ES2022",
    "lib": ["ES2022"],
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "types": [],
    "noEmitOnError": true,
    "outDir": "build"
  },
  "files": ["solution.mts"]
}
tsc --version
cp starter.mts solution.mts
tsc --project tsconfig.json
node checks.mjs ./build/solution.mjs

Typed listeners receive unknown arguments and can narrow them when needed. This keeps the exercise focused on dispatch behavior rather than a generic event-type library. Consult the course's environment setup guidance if a tool is unavailable.

Starters

JavaScript starter: starter.mjs

export class EventEmitter {
  on(event, handler) {
    throw new Error('Implement on')
  }

  off(event, handler) {
    throw new Error('Implement off')
  }

  emit(event, ...args) {
    throw new Error('Implement emit')
  }

  once(event, handler) {
    throw new Error('Implement once')
  }
}

TypeScript starter: starter.mts

type EventName = string | symbol
type Listener = (this: void, ...args: unknown[]) => void

export class EventEmitter {
  on(event: EventName, handler: Listener): () => void {
    throw new Error('Implement on')
  }

  off(event: EventName, handler: Listener): void {
    throw new Error('Implement off')
  }

  emit(event: EventName, ...args: unknown[]): void {
    throw new Error('Implement emit')
  }

  once(event: EventName, handler: Listener): () => void {
    throw new Error('Implement once')
  }
}

Visible checks

Save these checks as checks.mjs.

import assert from 'node:assert/strict'
import test from 'node:test'

const { EventEmitter } = await import(process.argv[2] ?? './solution.mjs')

test('normal: ordered synchronous delivery preserves arguments and plain this', () => {
  const bus = new EventEmitter()
  const received = []
  const payload = {}
  bus.on('ready', function (...args) {
    assert.equal(this, undefined)
    received.push(['first', ...args])
  })
  bus.on('ready', (...args) => received.push(['second', ...args]))
  assert.equal(bus.emit('ready', payload, 7), undefined)
  assert.deepEqual(received, [['first', payload, 7], ['second', payload, 7]])
  assert.equal(received[0][1], payload)
})

test('edge: duplicate registrations and idempotent per-registration disposers', () => {
  const bus = new EventEmitter()
  let count = 0
  const handler = () => { count += 1 }
  const disposeFirst = bus.on('tick', handler)
  bus.on('tick', handler)
  bus.emit('tick')
  assert.equal(count, 2)
  disposeFirst()
  disposeFirst()
  bus.emit('tick')
  assert.equal(count, 3)
  assert.equal(bus.off('tick', handler), undefined)
  bus.on('tick', handler)
  disposeFirst()
  bus.emit('tick')
  assert.equal(count, 4)
})

test('edge: off removes regular and once registrations by the original handler', () => {
  const bus = new EventEmitter()
  let count = 0
  const handler = () => { count += 1 }
  const dispose = bus.once('tick', handler)
  bus.once('tick', handler)
  bus.on('tick', handler)
  bus.off('tick', handler)
  dispose()
  bus.emit('tick')
  assert.equal(count, 0)
  bus.off('missing', handler)
  assert.equal(bus.emit('missing'), undefined)
})

test('edge: snapshot additions wait and removals before a turn cancel it', () => {
  const bus = new EventEmitter()
  const order = []
  const late = () => order.push('late')
  const removed = () => order.push('removed')
  bus.on('tick', () => {
    order.push('first')
    bus.off('tick', removed)
    bus.on('tick', late)
  })
  bus.on('tick', removed)
  bus.emit('tick')
  assert.deepEqual(order, ['first'])
  bus.emit('tick')
  assert.deepEqual(order, ['first', 'first', 'late'])
})

test('edge: once is removed before it makes a nested emission', () => {
  const bus = new EventEmitter()
  const order = []
  const dispose = bus.once('tick', () => {
    order.push('once')
    bus.emit('tick')
  })
  bus.on('tick', () => order.push('regular'))
  bus.emit('tick')
  dispose()
  assert.deepEqual(order, ['once', 'regular', 'regular'])
})

test('edge: a once consumed by a nested emission is skipped by an older snapshot', () => {
  const bus = new EventEmitter()
  const order = []
  let nested = false
  bus.on('tick', () => {
    order.push(nested ? 'inner' : 'outer')
    if (!nested) {
      nested = true
      bus.emit('tick')
      nested = false
    }
  })
  bus.once('tick', () => order.push('once'))
  bus.emit('tick')
  assert.deepEqual(order, ['outer', 'inner', 'once'])
})

test('error: dispatch is fail-fast and a throwing once remains consumed', () => {
  const bus = new EventEmitter()
  const error = new Error('listener failed')
  let later = 0
  bus.once('error', () => { throw error })
  bus.on('error', () => { later += 1 })
  assert.throws(() => bus.emit('error'), value => value === error)
  assert.equal(later, 0)
  bus.emit('error')
  assert.equal(later, 1)
})

test('edge: event identity, separate emitters, and obsolete disposers', () => {
  const bus = new EventEmitter()
  const other = new EventEmitter()
  const event = Symbol('event')
  let count = 0
  const dispose = bus.once(event, () => { count += 1 })
  bus.emit(Symbol('event'))
  other.emit(event)
  assert.equal(count, 0)
  bus.emit(event)
  const next = bus.on(event, () => { count += 10 })
  dispose()
  bus.emit(event)
  assert.equal(count, 11)
  next()
  bus.emit(event)
  assert.equal(count, 11)
})

Take a moment to think about this before revealing the answer

Explain your reasoning or try an implementation before comparing answers.

Explore the full Design Patterns material

Premium includes the complete lessons and implementation references. Free practice questions remain available without a subscription.

All course tracks & premium content
From basics to advanced masterclasses
Built for JS/TS developers like you
Real-world tips & common pitfalls
Upgrade to Premium