TypeScript by Example

Designing Tagged Shapes

Why loose object shapes do not narrow, and how to parse unknown JSON into strict tagged unions.

The tag only helps when each variant is exact. A loose object with optional fields still leaves TypeScript unsure.

If kind is just string, TypeScript cannot know that "circle" means radius exists.

type LooseShape = {
  kind: string;
  radius?: number;
  width?: number;
  height?: number;
};
 
function describeLoose(shape: LooseShape): string {
  if (shape.kind === "circle") {
    // Error: shape.radius is possibly undefined.
    // @ts-expect-error loose shapes do not narrow optional fields
    return `circle ${shape.radius.toFixed(1)}`;
  }
  return "unknown";
}

Keep loose data at the edge. Check JSON once, then convert it into the strict union used by normal code.

type RawEvent = { kind?: string; userId?: unknown };
type DomainEvent = { kind: "user.created"; userId: string };
 
function parseEvent(raw: RawEvent): DomainEvent | null {
  if (raw.kind === "user.created" && typeof raw.userId === "string") {
    return { kind: "user.created", userId: raw.userId };
  }
  return null;
}

In production

Do not spread optional fields across one loose mega-shape. Parse external data into a strict union at the boundary, then let the rest of the code rely on exact tags and required fields.

Enjoyed this? Get more software craft delivered to your inbox.