skip to content

A service returns a bag of feature flags keyed by flag name. In TypeScript, argue the tradeoffs between typing it as an open dictionary with a string index signature and typing it as a closed object type with one property per known flag.

level: principalimportance: should knowfreq 30%

answer

  1. who owns the key set
  2. open keys, closed model
  3. the type is a claim, not a check
  4. translate once at the boundary
  5. staleness versus typo safety

basics

~20 s

An open dictionary never goes stale but gives up autocomplete, typo detection and any presence guarantee; a closed object type restores all three but becomes a false claim the moment the service ships a flag it does not list. The usual answer is both, separated by a validating boundary.

solid answer

~50 s

The real question is who owns the key set. An index signature such as `{ [flag: string]: boolean }` is honest about data you do not control: it never goes stale, and it costs you autocomplete, typo checking and exhaustive handling, so every read needs an `undefined` story. A closed type — one property per flag — buys back all of that and makes the compiler tell you when a flag is retired, but it is a **claim** about a payload you did not produce, and since types are erased nothing verifies it at run time; the day the service adds a flag, your type is silently wrong. In practice I would type the wire shape as a dictionary, validate and translate it once at the boundary into a closed type with explicit defaults, and let all downstream code use the closed type. That confines the uncertainty to one function instead of spreading `undefined` checks through the app.

code

typescript · 13 lines
typescript
interface Flags { newCheckout: boolean; darkMode: boolean }
type RawFlags = { [flag: string]: boolean };

// One place decides what a missing flag means
function toFlags(raw: RawFlags): Flags {
  return {
    newCheckout: raw["newCheckout"] ?? false,
    darkMode: raw["darkMode"] ?? false,
  };
}

const flags = toFlags({ newCheckout: true, someNewFlag: true });
console.log(flags.darkMode);

go deeper

for a junior

Know the two options and their headline difference: a dictionary accepts any key, a closed object type gives you autocomplete and catches typos.

for a middle

Explain what each choice costs at the call site — narrowing and missing-key handling versus staleness — and be able to write the translation function between them.

for a senior

Put the boundary in the right place: dictionary at the wire, closed model downstream, one function that resolves missing keys with explicit defaults and validates untrusted input.

for a principal

Own the consequences beyond the type — how staleness is detected, how retired flags get cleaned up, and whether the failure mode of a wrong type is centralised or scattered across call sites.

## Frame the decision correctly This is not "which type is nicer". It is: **who owns the key set, and when does it change?** - If keys are added by deploying *your* code, they are closed. A code change accompanies every new key anyway, so the type can list them. - If keys are added by deploying *someone else's* code — another service, a config system, an admin UI, a customer — they are open. A type that lists them is a snapshot that goes stale without anyone noticing. Feature flags are the interesting case because they look closed (a developer created each one) but behave open (the payload comes over the network from a system with its own release cycle). ## What the open dictionary gives and costs ```ts type RawFlags = { [flag: string]: boolean }; ``` Gives: it is never wrong. Any payload satisfies it, new flags need no code change, and the type makes no promise it cannot keep. Costs, all of them concrete: - **No autocomplete.** Nobody can discover flag names from the type. - **No typo detection.** `flags["newChekout"]` is a valid read; with optimistic index typing it is even typed `boolean`, so a typo silently becomes `false`-ish behaviour at run time. - **No presence story.** Under `noUncheckedIndexedAccess` every read is `boolean | undefined` and every call site must decide what missing means. Without the flag, that decision is skipped and the bug ships. - **No exhaustiveness.** Nothing tells you a flag is unused, or that a retired flag is still being read. ## What the closed type gives and costs ```ts interface Flags { newCheckout: boolean; darkMode: boolean } ``` Gives: autocomplete, typo errors, find-all-references for a flag, and a compiler error when a flag is removed — which is how flags actually get cleaned up rather than accumulating forever. Costs: - **It goes stale.** A flag added server-side is invisible to the type until someone updates it. - **It is unverified.** TypeScript erases types, so declaring the response as `Flags` — with `as`, or with a lying return annotation on a fetch wrapper — checks nothing at run time. The type is a promise, not a parse. - **It couples releases.** Consuming a new flag requires a code change, which is usually fine and occasionally exactly the wrong constraint. ## The answer that holds up: separate the wire from the model The two types are describing different things, so stop choosing between them: ```ts interface Flags { newCheckout: boolean; darkMode: boolean } type RawFlags = { [flag: string]: boolean }; function toFlags(raw: RawFlags): Flags { return { newCheckout: raw["newCheckout"] ?? false, darkMode: raw["darkMode"] ?? false, }; } ``` The dictionary describes the payload truthfully. One function turns it into the model the application wants, and that is the single place where "what if it is missing" is answered — with an explicit default per flag, which is a product decision that deserves to be written down rather than implied by `undefined`. Downstream code sees only `Flags`: precise, autocompleted, exhaustively checkable. This is the general shape of the answer at a boundary, not a flag-specific trick. Untrusted shape in, validated model out, and the index signature confined to the outermost layer. If the payload is genuinely untrusted, the same seam is where a run-time validator belongs — the type layer cannot check anything by itself. ## When to stay open all the way through Sometimes the closed type is the wrong ambition. A telemetry attribute bag, arbitrary user-defined labels, HTTP headers, an i18n message catalogue keyed by message id — these have genuinely unbounded key sets, and generating a closed type for them produces a huge type that changes constantly and helps nobody. Keep those as dictionaries, keep `noUncheckedIndexedAccess` on so the reads stay honest, and put the precision into the *value* type instead of the key type. A middle option, when key names are owned by your code but the collection is dynamic, is a closed key set expressed as a union of string literal types, which gives typo-checked keys without listing every property by hand — that is where `Record` with a literal key union earns its place. ## Second-order consequences to name A principal-level answer should reach past the type: - **Staleness detection.** If you go closed, how do you learn that the service added a flag? A test against the real payload, a generated type from a schema, or a log line for unknown keys are all better than hoping. - **Cleanup.** Closed types are how dead flags get found. That is often the strongest argument for them, and it is an organisational benefit, not a typing one. - **Blast radius.** A wrong closed type fails at the first use of a value; a permissive dictionary fails wherever someone forgot a check. Neither is safe by default — the difference is whether the failure is centralised. ## What a weak answer looks like "Use `Record<string, boolean>`, it's cleaner" is not an answer; nor is "always generate the exact type". The interviewer is listening for the ownership question, the observation that a type over foreign data is an unverified claim, and a boundary where the uncertainty is resolved once.

  • If you choose the closed type, how do you find out that the service has added a flag you do not list?
    Not from the compiler — it never sees the payload. You need a run-time signal: log or report unknown keys at the parse boundary, assert against a real response in a contract test, or generate the type from the flag service's schema so a new flag shows up as a diff. Without one of those, the closed type quietly drifts.
  • Where does a run-time validator fit into this, given types are erased?
    Exactly at the seam where the dictionary becomes the model. Validation is the only thing that makes the closed type true; the annotation alone is a promise. Validate once, at the boundary, and let everything downstream trust the model — validating repeatedly deeper in the app is both slower and a sign the boundary is in the wrong place.
  • When is keeping the dictionary all the way through the right call?
    When the key set is genuinely unbounded and no code names individual keys — telemetry attributes, user-defined labels, an i18n catalogue keyed by message id. Generating a closed type there produces churn and no safety. Keep honest lookup types on, and invest the precision in the value type rather than the key type.

saying these in an interview costs you the question

  • Treats the choice as a style preference rather than key-set ownership
  • Assumes annotating the response as a closed type validates it
  • Adds an index signature to a shape whose keys are actually known
  • Sprinkles undefined checks through the app instead of parsing once
  • Says a generated exact type removes the need for run-time validation

context