Building a Type-Safe Terminal Command Registry in TypeScript
The terminal widget on this site (try help in it right now) isn't a pile of
if/else branches. Each command is a small, typed record in a registry:
export interface TerminalCommand {
readonly description: string;
readonly run: (args: readonly string[]) => readonly string[];
}
export const TERMINAL_COMMANDS: Readonly<Record<string, TerminalCommand>> = {
whoami: {
description: "Print who this terminal belongs to",
run: () => ["Abdul Wahab — Principal Software Engineer"],
},
// ...
};Adding a command means adding an entry, not touching a dispatch function. That's the whole point of modeling behavior as data — it satisfies the Single Responsibility Principle at the level of each command, not just each file.
The closure/narrowing bug
The first draft of the canvas particle background (a separate, earlier ticket) hit a real TypeScript gap: null-narrowing from an early-return guard does not survive into a nested function declaration.
const canvas = canvasRef.current;
if (!canvas) return;
function draw() {
canvas.width = 100; // Error: 'canvas' is possibly 'null'
}Even though canvas is a const that's provably non-null by the time
draw is ever called, TypeScript can't see that — the narrowing is a
control-flow fact tied to where the check happened, and it doesn't
propagate into a closure that might run at some unknown future time.
The fix isn't a
!assertion. Rebinding to a freshconstright after the guard works, because the new variable's declared type is non-nullable — that's a static fact, not a narrowing fact — so it stays correct inside any closure that references it.
const canvasElement = canvasRef.current;
if (!canvasElement) return;
const canvas = canvasElement; // declared type: HTMLCanvasElement
function draw() {
canvas.width = 100; // fine — no assertion needed
}Why noUncheckedIndexedAccess matters
This project's tsconfig.json enables noUncheckedIndexedAccess, which
adds | undefined to every indexed access — array element, object index
signature, destructured array element — that TypeScript can't prove is in
bounds.
| Access pattern | Type without the flag | Type with the flag |
|---|---|---|
arr[i] | T | T | undefined |
record[key] | T | T | undefined |
const [a] = str.split(" ") | string | string | undefined |
It's caught real bugs in this codebase at build time — most memorably a
const [name] = trimmed.split(/\s+/) in the terminal's command parser,
which is provably safe (a non-empty string always splits into at least one
element) but not statically provable to the compiler. The fix was a
one-line ?? "" fallback, not a suppressed error.
Takeaways
- Model behavior as data (a registry) when the set of "things" is open-ended and each one does one job — it keeps SRP at a granular level.
- Closures don't inherit control-flow narrowing. If a nested function needs a non-null value, give it one via a freshly-declared variable, not an assertion.
- Strict compiler flags like
noUncheckedIndexedAccessare worth the friction — every error it raises here was a genuine edge case, not noise.