← Writing

Use TypeScript to rule out bad states

A lot of TypeScript code is just JavaScript with labels.

The shape of the value is typed. The code still allows states you never wanted to permit.

That is the line between descriptive types and constraining types. Descriptive types tell you what a value looks like. Constraining types make certain bad states impossible to represent.

Here is a weak model for a text cursor:

interface CursorState {
  position: number;
  isSelecting: boolean;
  selectionAnchor: number | null;
}

It seems reasonable until you look at the combinations it allows.

This compiles:

const broken: CursorState = {
  position: 12,
  isSelecting: true,
  selectionAnchor: null,
};

That state does not make sense. But the type cannot stop it, because the model is built from unrelated fields rather than real modes.

The better model is a discriminated union:

type CursorState =
  | {
      readonly mode: 'caret';
      readonly position: number;
    }
  | {
      readonly mode: 'selecting';
      readonly anchor: number;
      readonly head: number;
    }
  | {
      readonly mode: 'dragging';
      readonly handle: 'start' | 'end';
      readonly selection: {
        readonly start: number;
        readonly end: number;
      };
    }
  | {
      readonly mode: 'ime';
      readonly composition: string;
      readonly range: {
        readonly start: number;
        readonly end: number;
      };
      readonly originalPosition: number;
    };

Now the invalid combination is gone. A selecting state must have an anchor because that is what “selecting” means.

The consumer code also gets better because the switch matches the real state machine:

function cursorOffset(state: CursorState): number {
  switch (state.mode) {
    case 'caret':
      return state.position;
    case 'selecting':
      return state.head;
    case 'dragging':
      return state.handle === 'start'
        ? state.selection.start
        : state.selection.end;
    case 'ime':
      return state.range.end;
  }
}

That is the first big use of TypeScript: model the actual states of the system instead of approximating them with flags.

The second useful move is branded types.

A lot of domains have values that are all strings at runtime and completely different in meaning. Node IDs, layer IDs, user IDs, project IDs. Plain string types cannot protect you from mixing them up.

type NodeId = string & { readonly __brand: 'NodeId' };
type LayerId = string & { readonly __brand: 'LayerId' };

function nodeId(id: string): NodeId {
  return id as NodeId;
}

function layerId(id: string): LayerId {
  return id as LayerId;
}

function findNode(root: SceneNode, id: NodeId): SceneNode | undefined {
  /* ... */
}

function findLayer(layers: Layer[], id: LayerId): Layer | undefined {
  /* ... */
}

Now the wrong call becomes a type error:

const id = layerId('layer-123');
// findNode(root, id); // Type error

That costs nothing at runtime and catches a real class of mistakes.

The third move is exhaustive checking. When your domain grows, forgotten cases are a common source of bugs. TypeScript can help there too.

function assertNever(x: never): never {
  throw new Error(`Unexpected value: ${JSON.stringify(x)}`);
}

function renderNode(node: SceneNode): void {
  switch (node.type) {
    case 'rect':
      renderRect(node);
      return;
    case 'ellipse':
      renderEllipse(node);
      return;
    case 'path':
      renderPath(node);
      return;
    case 'text':
      renderText(node);
      return;
    case 'group':
      renderGroup(node);
      return;
    default:
      assertNever(node);
  }
}

If someone adds a new node type later and forgets to handle it here, the compiler points to the gap.

That is TypeScript doing real work. Not labeling. Enforcing.

One more pattern is worth keeping around: capability-driven types. This is useful when the shape of one value depends on another.

interface PluginCapabilities {
  rendering: boolean;
  input: boolean;
  storage: boolean;
}

type PluginHooks<C extends PluginCapabilities> =
  & (C['rendering'] extends true ? { onRender(frame: FrameContext): void } : {})
  & (C['input'] extends true ? { onInput(event: InputEvent): boolean } : {})
  & (C['storage'] extends true ? {
      onSave(): SerializedData;
      onLoad(data: SerializedData): void;
    } : {});

interface PluginDefinition<C extends PluginCapabilities> {
  readonly id: string;
  readonly capabilities: C;
  readonly hooks: PluginHooks<C>;
}

const renderOnlyPlugin: PluginDefinition<{
  rendering: true;
  input: false;
  storage: false;
}> = {
  id: 'grid-overlay',
  capabilities: {
    rendering: true,
    input: false,
    storage: false,
  },
  hooks: {
    onRender(frame) {
      drawGrid(frame);
    },
  },
};

Now a plugin that declares input: false cannot quietly expose onInput. The contract stays aligned.

The common thread through all of these patterns is simple. TypeScript becomes worth the trouble when it starts ruling out bad states and bad combinations before the program runs.

That does not mean every type needs to be clever. Quite the opposite. The best type-driven designs usually feel plain in use because they moved the complexity into the model where it belongs.

The question to keep asking is this: does this type merely describe the data, or does it actively prevent mistakes the system should never allow?

The second kind is where the value is.

View on Bearblog