TypeScript

TypeScript Record: Type a Fixed Key Set or an Open Dictionary

Learn when TypeScript Record should require a fixed set of keys and when it should describe an open string-keyed dictionary.

Editorial illustration for TypeScript Record: Type a Fixed Key Set or an Open Dictionary

The first question to ask about a lookup object is not “Should this be a Record?” It is “Do I know all its keys?” A deployment table with exactly three environments has a different contract from a dictionary of labels added as needed. Record can describe either one, but the choice of key type changes what the checker expects.

What Record describes

TypeScript’s Record<Keys, Type> utility constructs an object type: the selected property keys share a value type. It is not a collection you instantiate, and it does not add methods to the resulting object. The mapped-type definition expresses the idea as a property of type T for each key in K.

That makes Record useful for a table whose values follow one structure. It also means the key argument deserves attention. A finite union names the entries the table must contain; string describes an open set of string keys.

Require every entry in a finite key set

Suppose each deployment environment needs a configuration entry. Name the environments separately from the shape of each entry:

type Environment = "development" | "staging" | "production";

interface DeploymentConfig {
  endpoint: string;
  replicas: number;
}

type DeploymentTable = Record<Environment, DeploymentConfig>;

const deployments: DeploymentTable = {
  development: { endpoint: "https://dev.example.test", replicas: 1 },
  staging: { endpoint: "https://stage.example.test", replicas: 2 },
  production: { endpoint: "https://example.test", replicas: 4 },
};

const productionEndpoint = deployments.production.endpoint;

Here, the union is the checklist. The complete object supplies a DeploymentConfig for each named environment. The following are deliberately invalid assignments; keep them separate from code you expect to compile:

// Expected checker error: the production entry is missing.
const missingEnvironment: DeploymentTable = {
  development: { endpoint: "https://dev.example.test", replicas: 1 },
  staging: { endpoint: "https://stage.example.test", replicas: 2 },
};

// Expected checker error: preview is not a listed key in this fresh object literal.
const unlistedEnvironment: DeploymentTable = {
  development: { endpoint: "https://dev.example.test", replicas: 1 },
  staging: { endpoint: "https://stage.example.test", replicas: 2 },
  production: { endpoint: "https://example.test", replicas: 4 },
  preview: { endpoint: "https://preview.example.test", replicas: 1 },
};

// Expected checker error: replicas must be a number.
const wrongValue: DeploymentTable = {
  development: { endpoint: "https://dev.example.test", replicas: 1 },
  staging: { endpoint: "https://stage.example.test", replicas: 2 },
  production: { endpoint: "https://example.test", replicas: "four" },
};

This is checking the declared shape, not proving that every configuration the application might receive has been validated. In particular, the unlisted-key example concerns a fresh object literal assigned to the finite-key type; do not read it as a promise that arbitrary runtime objects cannot contain other properties.

Leave the keys open for a dictionary

If labels can be added under different string keys, enumerating every possible key defeats the purpose. Record<string, string> constrains values without requiring an entry for every string. An empty dictionary and a dictionary with a few entries both fit:

const messages: Record<string, string> = {};
messages["queued"] = "Waiting";

const buttonLabels: Record<string, string> = {
  save: "Save",
};
buttonLabels["cancel"] = "Cancel";

const saveLabel = buttonLabels.save;
const requestedKey: string = "cancel";
const requestedLabel = buttonLabels[requestedKey];

// Expected checker error: dictionary values must be strings.
buttonLabels["retry"] = 42;

The string-keyed dictionary example in the Record discussion likewise starts with {} and adds entries by key. Run the accepted assignments through your project’s TypeScript checker, then include the deliberately invalid lines to inspect what it rejects. The expected distinction is about key coverage: unlike DeploymentTable, messages need not anticipate every key someone may later use.

Read a property; check presence when it matters

Both examples use ordinary object access. Dot notation is convenient for a known name such as deployments.production; brackets accept a string expression such as buttonLabels[requestedKey]. Neither form is a Record-specific get method.

Be careful with an open dictionary lookup. Declaring its values as string does not establish that a particular runtime key was inserted. If the next step depends on the property being present, check that condition where you read it:

if (Object.prototype.hasOwnProperty.call(buttonLabels, requestedKey)) {
  const label = buttonLabels[requestedKey];
  console.log(label);
}

That check addresses property presence; it does not validate the contents of an object received from an untrusted source. A type annotation alone does not validate incoming data. At runtime, a Record-typed value is an ordinary JavaScript object, not a separate collection with its own validation or iteration API. To visit its entries, use ordinary object facilities such as Object.entries(buttonLabels); the resulting array can then use array methods such as forEach.

Choose the finite union when missing one of a known set of entries is a mistake. Choose string when keys are genuinely open-ended, and handle absent keys at the point where their presence matters.

Find a note

Search by topic, title, or keyword.