A working TypeScript reference implementation of a multi-elevator system, packaged as a one-stop preparation guide for the "design an elevator system" low-level design (LLD) interview. It's meant to be read top to bottom before an interview and used as a quick reference during one.
The code is intentionally small and dependency-free so you can read all of it in under ten minutes, then spend the rest of your prep time on the reasoning — the part interviewers actually score.
Note
After exploring this repository, check out the accompanying Substack article to learn more about the design, implementation, and interview approach.
- Quick start
- Project structure
- The problem statement
- Step 1 — Clarifying questions
- Step 2 — Scoping it down
- Functional requirements
- Non-functional requirements
- Core entities
- Class diagram
- Public API
- Design decisions & rationale
- Algorithm walkthrough
- Dispatch strategy walkthrough
- Design patterns you can point to
- What a staff engineer would flag in this code
- Complexity analysis
- Edge cases to talk through out loud
- Extending the design — likely follow-up questions
- General LLD interview gotchas
- Suggested time-boxing for a 45–60 min interview
- Further reading
npm install # install dev dependencies (typescript, ts-node)
npm run build # type-check and compile src/ -> dist/
npm start # run the demo simulation (src/demo.ts)npm start spins up an ElevatorController, fires a few hall calls, and ticks the
simulation forward with controller.step() until every elevator is idle — printing the
fleet's floor/direction/pending-count after every tick so you can see the dispatch and
movement logic in action.
src/
├── types/
│ ├── Direction.ts # UP / DOWN / IDLE enum
│ └── Request.ts # RequestType enum + the Request value object
├── core/
│ ├── Elevator.ts # single-elevator state machine (movement, servicing)
│ └── ElevatorController.ts # fleet owner + dispatch strategy
├── demo.ts # runnable simulation for manual/visual verification
└── index.ts # public barrel export
This mirrors how you should structure the whiteboard/live-code version too: value objects and enums first, a single unit's behavior second, the orchestrator/fleet manager last. It also maps directly onto the entities you'd name in the first five minutes of the interview — see Core entities.
Design an elevator system for a building. Support calling an elevator to a floor, moving it, and servicing requests efficiently as elevators travel.
That's usually all you're given. Everything else in this document is about turning that one sentence into a scoped, defensible design before you write a single line of code.
Ask these before you draw a single box. In a real interview, the answers you get (or don't get, forcing you to state an assumption) directly shape your class diagram — asking them is itself a scored signal, not throat-clearing.
Scale & topology
- How many floors, and how many elevators? Fixed at design time, or configurable?
- One building or a bank shared across a lobby (e.g. low-rise/high-rise zoning)?
Request model
- Do we need both hall calls (up/down buttons on a floor) and car calls (destination-floor buttons inside the cab)? (Spoiler: this repo only implements the former — see What a staff engineer would flag.)
- Can a rider request multiple stops, or one destination per boarding?
Behavior & policy
- What's the dispatch goal — minimize average wait time, minimize max wait time (fairness/no-starvation), or energy usage?
- Do we need special modes: capacity/weight limits, express floors, fire/emergency recall, maintenance/out-of-service, VIP or accessibility priority?
- Do doors need open/close timing and dwell time, or is arrival instantaneous for this exercise?
System qualities
- Is this a single-process simulation (discrete time steps, one thread) or does it need to handle real concurrent requests (multiple threads/processes calling in at once)?
- Does state need to survive a restart (persistence), or is in-memory fine?
- Do we need to expose live position/ETA to a caller or UI?
Whatever answers you get (or don't), restate your assumptions out loud before moving on. That's what "scoping down" means in practice.
For a 45–60 minute interview you cannot design the whole thing. Explicitly narrow to an MVP and say so — this is exactly what this repo's code commits to:
In scope (what's implemented here)
- A fixed-size fleet of elevators serving a fixed floor range.
- Hall calls only:
PICKUP_UP/PICKUP_DOWNrequests originating from a floor. - A greedy nearest/committed-elevator dispatch strategy.
- Discrete-time simulation via an explicit
step()tick — no real timers, no threads. - In-memory state only.
Explicitly out of scope (say this, then move on unless asked)
- Car calls / destination selection once a rider has boarded.
- Capacity, weight limits, overload detection.
- Multiple elevator banks/zones, express floors.
- Fire/emergency/maintenance modes.
- Persistence, distributed coordination, real concurrency/locking.
- Door timing, energy optimization, live ETA/UI integration.
Naming what you're deliberately not building is worth as much as naming what you are — it shows you understand the full problem space even though you're only solving a slice of it.
| # | Requirement |
|---|---|
| F1 | A rider can call an elevator to a floor, specifying a direction (up/down). |
| F2 | The system assigns the call to a single "best" elevator. |
| F3 | Each elevator advances one floor per simulation tick toward its current direction. |
| F4 | An elevator services (clears) a request when it arrives at a matching floor. |
| F5 | An elevator reverses direction once it has no more requests ahead of it in its current direction. |
| F6 | An elevator goes idle once it has no pending requests. |
| F7 | Duplicate hall calls for the same floor + direction are de-duplicated. |
| F8 | Out-of-range floor requests are rejected. |
| # | Requirement | How this repo addresses it |
|---|---|---|
| N1 | Fairness / no starvation — every accepted request is eventually serviced. | The LOOK-style sweep (reverse only at the last pending floor) guarantees every request in an elevator's set is reached; the dispatcher never drops a request — see Dispatch strategy walkthrough. |
| N2 | Efficiency — minimize wasted travel / wait time. | Approximated with a 3-tier greedy heuristic, not a globally optimal assignment — see below. |
| N3 | Determinism / testability — the same input sequence always produces the same outcome. | Explicit step() ticking with no timers, randomness, or I/O makes this trivial to unit test. |
| N4 | Scalability — should generalize beyond 3 elevators / 10 floors. | Currently hardcoded (see staff-engineer flags) but the algorithms are O(elevators × pending requests), not floor-count-dependent, so it scales fine in principle. |
| N5 | Consistency — no two components disagree about an elevator's state. | Single-threaded synchronous ticking sidesteps concurrency entirely; a real system would need this called out explicitly (see clarifying questions). |
Direction—UP | DOWN | IDLE. An elevator's current motion state.RequestType—PICKUP_UP | PICKUP_DOWN | DESTINATION. What kind of stop is being requested: a hall call in a given direction, or (in the domain model only — see below) a car call to a destination floor.Request— an immutable value object:(floor, type)plusequals(). Two requests are equal iff both fields match.Elevator— a single cab's state machine:currentFloor,direction, and aSet<Request>of pending stops. Owns movement and servicing logic (step,addRequest,hasRequestsAhead,hasRequestsAtOrBeyond).ElevatorController— the fleet owner / facade. ExposesrequestElevator(floor, type)(the hall-call entry point) andstep()(advances every elevator by one tick), and privately implements the dispatch heuristic.
classDiagram
class Direction {
<<enumeration>>
UP
DOWN
IDLE
}
class RequestType {
<<enumeration>>
PICKUP_UP
PICKUP_DOWN
DESTINATION
}
class Request {
-floor: number
-type: RequestType
+getFloor() number
+getType() RequestType
+equals(other: Request) boolean
}
class Elevator {
-currentFloor: number
-direction: Direction
-requests: Set~Request~
+addRequest(request: Request) boolean
+step() void
+hasRequestsAhead(dir: Direction) boolean
+hasRequestsAtOrBeyond(floor: number, dir: Direction) boolean
+getCurrentFloor() number
+getDirection() Direction
+getPendingCount() number
}
class ElevatorController {
-elevators: Elevator[]
+step() void
+getElevators() Elevator[]
+requestElevator(floor: number, type: RequestType) boolean
-selectBestElevator(request: Request) Elevator
-findCommittedToFloor(request: Request) Elevator
-findNearestIdle(floor: number) Elevator
-findNearest(floor: number) Elevator
}
ElevatorController "1" *-- "many" Elevator
Elevator "1" o-- "many" Request
Request --> RequestType
Elevator --> Direction
| Class | Method | Purpose |
|---|---|---|
ElevatorController |
requestElevator(floor, type) |
Entry point for a hall call. Validates bounds, rejects DESTINATION, dispatches to the best elevator. |
ElevatorController |
step() |
Advances every elevator in the fleet by one simulation tick. |
ElevatorController |
getElevators() |
Read-only view of the fleet, for monitoring/UI/tests. |
Elevator |
addRequest(request) |
Adds a stop to this elevator's set (bounds-checked, de-duplicated). |
Elevator |
step() |
Advances this elevator by one tick: pick direction if idle, service the current floor, reverse or move. |
Elevator |
getCurrentFloor() / getDirection() / getPendingCount() |
State getters for dispatch decisions and observability. |
elevator-simulation-demo.mov
Discrete step() ticking instead of timers/threads.
There's no setInterval, no async movement, no concurrency anywhere. The caller drives
time forward explicitly. This is a deliberate interview-friendly simplification: it makes
the whole system a pure function of (state, ticks), trivially unit-testable, and sidesteps
a huge, separate discussion (thread-safety, locking per elevator, race conditions on
shared floor-call queues) that you should name explicitly as a scoping decision rather
than silently ignore.
LOOK, not SCAN.
hasRequestsAhead() makes an elevator reverse direction as soon as nothing is left ahead
of it — it does not keep going until it hits floor 0 or floor 9 regardless of pending
work. That's the classic LOOK disk-scheduling algorithm (reverse at the last request),
as opposed to SCAN (sweep all the way to the physical end every pass). Naming this
correctly is a strong, cheap signal in an interview — elevator dispatch is one of the
canonical real-world analogies for disk-arm scheduling algorithms.
Two pickup types instead of one PICKUP + a direction field.
PICKUP_UP and PICKUP_DOWN are distinct RequestTypes rather than a single type with a
direction attribute. That lets hasRequestsAtOrBeyond(floor, dir) answer "does this
elevator's current sweep already plan to serve this exact hall call?" with a plain
equality check, keeping Request a trivial two-field value object instead of needing a
richer shape.
A 3-tier greedy heuristic instead of a global cost function.
selectBestElevator doesn't score every elevator against every possible cost metric. It
checks, in order: (1) an elevator already sweeping the right way that will pass this floor
anyway, (2) the nearest idle elevator, (3) the nearest elevator regardless of state. This
is explainable in one sentence and cheap to compute, at the cost of not being globally
optimal across simultaneously-arriving requests — a good, honest trade-off to state
proactively.
Set<Request> plus a hand-rolled equals().
JavaScript's Set dedupes by reference identity, not value — so two different Request
instances with the same (floor, type) are not automatically merged by the Set itself.
hasRequest()/removeRequest() compensate with a manual linear scan calling .equals().
Understanding why this is necessary (and its cost — see below) is a good sign you've
actually read the code rather than skimmed it.
Elevator.step(), traced in order:
-
No pending requests? Set
direction = IDLEand do nothing else this tick. -
Currently idle with pending requests? Scan all pending requests for the nearest one by absolute floor distance (ties broken toward the lower floor), and commit to
UPorDOWNtoward it.Subtlety worth raising in an interview: this direction choice is purely distance-based — it does not check whether the nearest pending request is itself a
PICKUP_UPorPICKUP_DOWNcall. In a rare case (the only pending call is aPICKUP_DOWNat a floor above the elevator) the elevator commits toUP, arrives, finds the pickup type doesn't match, immediately reverses, and only then services it one tick later. Correctness is preserved (the LOOK loop self-corrects), but it's a wasted tick — a good "what would you change" follow-up answer. -
Service the current floor. Build the two request shapes that would apply right here — a pickup matching the current direction, and a
DESTINATION— and remove either from the pending set if present. If that emptied the set, goIDLEand stop; otherwise stop here for this tick either way (arriving and moving are mutually exclusive within one tick). -
Nothing serviced this floor. Anything left ahead in the current direction? If not, reverse direction (LOOK behavior) and stop — the reversal itself doesn't consume a movement tick.
-
Otherwise, move one floor in the current direction.
ElevatorController.selectBestElevator(request), in priority order:
findCommittedToFloor— is there an elevator already moving in the same direction the hall call needs, that hasn't passed the requested floor yet, and already has a request at or beyond that floor in that direction (i.e. it's guaranteed to sweep past here anyway)? If several qualify, pick the nearest. This is the "you're already going my way" fast path — no new detour needed.findNearestIdle— otherwise, hand it to the nearest elevator sittingIDLE.findNearest— otherwise, every elevator is busy going somewhere unhelpful right now; hand it to whichever is physically nearest anyway. It will pick up the request once its current sweep ends and it turns around (guaranteed by the LOOK loop, see N1 — nothing is ever dropped, just delayed).
Tip
Observable quirk (try it via npm start): if several elevators are idle at the
same floor and multiple hall calls arrive before any step() is called, all of them
land on the same elevator. Every tier picks the "nearest" with a strict <
comparison, so ties always resolve to the first elevator in iteration order — nothing
rebalances the load across the idle fleet. Good fodder for "how would you fix this?"
(answer: round-robin among tied candidates, or factor pending load into the tie-break.)
- Facade —
ElevatorControlleris already one: it hides fleet management and dispatch behind two methods (requestElevator,step). - Strategy (extension, not present) —
selectBestElevatorand its three helpers are a naturalDispatchStrategyinterface. Extracting it would let you swap in a different heuristic (or a real cost-function optimizer) without touchingElevatorController. - State (extension, not present) —
Directionis a plain enum withif/switchbranching on it inElevator. If an interviewer wants to see the pattern explicitly, refactor intoUpState/DownState/IdleStateclasses behind a commonElevatorStateinterface, each implementing its ownstep()/shouldReverse(). - Observer (extension, not present) — floor displays / a UI would subscribe to
arrival and direction-change events instead of polling
getCurrentFloor(). - Value Object —
Requestis immutable-by-convention (no setters) with value-basedequals(), which is exactly what backs the de-duplication behavior inElevator.addRequest.
Reading this code closely (not just skimming it) is itself good interview prep — these are the kinds of gaps a strong candidate is expected to notice and name, even if they choose not to fix them within the time box:
- There is no way to add a
DESTINATIONrequest.RequestType.DESTINATIONexists in the domain model andElevator.step()knows how to clear one — butElevatorController.requestElevatorexplicitly rejectsDESTINATION, and nothing else ever constructs one externally. In other words: car calls (picking a floor once you've boarded) are unimplemented. This is the single biggest functional gap and a great thing to point out unprompted — then sketch the fix: anaddDestination(elevatorId, floor)method onElevatorControllerthat forwards toElevator.addRequest(new Request(floor, RequestType.DESTINATION)). - O(n) linear scans where a
Mapwould give O(1).hasRequest/removeRequestwalk the wholeSetcalling.equals()because a JSSet<Request>dedupes by reference, not value. Keying aMap<string, Request>by a composite key like`${floor}:${type}`would make add/remove/has O(1) instead of O(n). Harmless at 10 floors; worth naming as a "wouldn't do this at scale" trade-off. - Hardcoded bounds. The floor range (
0–9) is a magic-number check duplicated in bothElevator.addRequestandElevatorController.requestElevator, and the fleet size (3) is baked into theElevatorControllerconstructor. Both should be constructor parameters (or injected config) for real configurability. getElevators(): readonly Elevator[]is a shallow read-only view — the array can't be mutated, but theElevatorobjects it contains still can be, sinceElevatorhas noreadonly/frozen state of its own. Fine for this scope; worth knowing the difference between "readonly array" and "immutable elements."- No concurrency story at all. Every mutation happens synchronously inside a single
step()call from a single caller. That's the right scope for this exercise, but say so explicitly rather than letting the interviewer assume you forgot about it.
Let E = number of elevators, n = pending requests on one elevator (bounded in practice
by floors × request types).
| Operation | Complexity | Why |
|---|---|---|
Elevator.addRequest |
O(n) | Linear hasRequest scan for de-duplication. |
Elevator.step |
O(n) | Nearest-request scan (idle branch) + ahead/removal checks, all linear in pending requests. |
ElevatorController.requestElevator |
O(E × n) | Each of the three dispatch tiers scans all E elevators, and hasRequestsAtOrBeyond is O(n) per elevator checked. |
ElevatorController.step |
O(E × n) | Ticks every elevator once. |
At the scale this repo targets (3 elevators, 10 floors) all of this is effectively constant time. The complexity is still worth stating precisely — "what if we had 50 elevators and 200 floors?" is a common follow-up, and the honest answer here is "still linear in fleet size and pending requests, not in floor count, so it scales fine as written."
- A request for the elevator's current floor —
addRequesttreats this as an immediate no-op success rather than queuing it. - A duplicate hall call (same floor + same direction) — de-duplicated via
hasRequest, secondaddRequestcall returnsfalse. - An out-of-range floor (negative, or above the top floor) — rejected at both
Elevator.addRequestandElevatorController.requestElevator. - Simultaneous hall calls while every elevator is idle at the same floor — all pile
onto one elevator due to strict-
<tie-breaking (see dispatch walkthrough). - A call for a floor the elevator has already passed, in its current direction —
findCommittedToFloorwon't match it (its floor-not-yet-passed check fails), so it routes to an idle or otherwise-nearest elevator instead of the one that just went by. - Two opposite-direction hall calls at the same floor (
PICKUP_UP@5andPICKUP_DOWN@5) — these are unequalRequests and coexist fine; an elevator passing floor 5 going up only clears theUPone. - The fleet fully drains — every elevator independently returns to
IDLEthe instant its own request set empties; there's no global "all done" signal (the demo's settle-loop polls each elevator's state to detect this).
Common ways interviewers push on this problem once the base case works, and where you'd touch the code for each:
| Follow-up | Where it lands |
|---|---|
| "Add destination/car calls." | Expose the addDestination method described above; Elevator.step() already handles clearing DESTINATION requests. |
| "Support weight/capacity limits." | New field on Elevator; reject addRequest/boarding once at capacity. |
| "Add an express elevator serving only floors 20+." | Give Elevator a floor-range/zone config instead of a shared global range; filter candidates in dispatch by zone. |
| "Make dispatch pluggable." | Extract selectBestElevator and its helpers behind a DispatchStrategy interface (see Strategy pattern). |
| "What if two requests arrive at the exact same instant from different threads?" | Name the concurrency gap directly; discuss a lock per elevator, a single-writer event queue, or an actor-per-elevator model. |
| "How do you prevent starvation under heavy load?" | Point to the LOOK loop's guarantee (nothing is ever dropped, only delayed) and discuss adding a max-wait-time priority boost if true fairness bounds are required. |
| "Fire/emergency mode." | A mode flag on ElevatorController that overrides dispatch and forces every elevator toward the ground floor, ignoring pending requests. |
Advice that applies beyond this specific problem:
- Don't start coding before you've scoped. Five minutes of clarifying questions and an explicit "here's my MVP, here's what I'm deferring" saves you from solving the wrong problem for 40 minutes.
- Name your assumptions out loud, even the ones nobody asked about (fixed floor count, single-threaded, no persistence). Silent assumptions read as "didn't think about it"; stated assumptions read as "chose to scope it out."
- Don't force design patterns in. Mention where Strategy/State/Observer/Factory would fit and why, rather than prematurely refactoring simple enum/if-else logic into four extra classes nobody asked for. Over-engineering under time pressure is as bad a signal as under-engineering.
- Reserve real time for working code. A beautiful class diagram with no runnable
logic is a weaker interview than a slightly rougher diagram plus a
step()method that actually compiles and traces correctly by hand. - Proactively raise non-functional requirements — fairness/starvation, efficiency, scalability — instead of waiting to be asked. This repo's own non-functional requirements section is a template for the kind of table you should be able to produce from memory.
- When you spot a gap in your own design mid-interview, say so. Pointing out "this doesn't yet handle car calls, here's how I'd add it" is a stronger signal than hoping the interviewer doesn't notice.
- Watch the clock. See the time-boxing suggestion below and adapt it live — if clarifying questions ate 15 minutes, cut scope further rather than rushing the code.
| Phase | Time | Goal |
|---|---|---|
| Clarify & scope | 5–8 min | Ask the clarifying questions, state your MVP. |
| Entities & class diagram | 5–8 min | Land on the shapes in Core entities — don't over-model. |
| Core algorithm, live-coded | 20–25 min | Movement/servicing loop (Elevator.step) and dispatch (selectBestElevator) — the two pieces actually worth points. |
| Trade-offs & extensions | 5–10 min | Walk through 1–2 items from staff-engineer flags or follow-ups unprompted. |
| Edge cases & testing | 5 min | Talk through edge cases — you don't need to write every test, just show you know where the corners are. |
- SCAN / LOOK / C-SCAN disk-scheduling algorithms — the classical CS analogy this problem is built on; understanding the elevator-arm-sweep intuition transfers directly.
- Destination dispatch systems — how modern high-rise elevator banks actually batch and optimize assignment across simultaneous calls, well beyond this repo's greedy per-request heuristic.
- The State design pattern — for turning
Direction-based conditionals into an explicit state machine, if an interviewer wants to see that structure.
Important
Once you've explored this repository, check out the accompanying Substack blog post for a deeper dive into the design decisions, implementation details, and interview discussion.