Abdul Wahab
Building a Type-Safe Terminal Command Registry in TypeScript
3 min read

Building a Type-Safe Terminal Command Registry in TypeScript

Intermediatetypescriptarchitecturereactguide

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 fresh const right 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 patternType without the flagType with the flag
arr[i]TT | undefined
record[key]TT | undefined
const [a] = str.split(" ")stringstring | 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 noUncheckedIndexedAccess are worth the friction — every error it raises here was a genuine edge case, not noise.