Skip to main content

Utility type record in TypeScript

Record<K, T> is a TypeScript utility type that creates an object type where every key from union K must be present and maps to type T.

Theory

TL;DR

  • Analogy: Record is like a spreadsheet template where column headers are fixed and every cell holds the same data type.
  • Main difference from { [key: string]: T }: Record forces ALL keys from your union to exist in the object; index signatures accept any string with no enforcement.
  • Internally, Record<K, T> compiles to { [P in K]: T }, which is a mapped type.
  • Decision rule: use Record when keys are known and finite. Use index signatures when keys are open-ended or dynamic.

Quick example

typescript
type Status = "pending" | "approved" | "rejected"; type StatusConfig = Record<Status, { label: string; color: string }>; const config: StatusConfig = { pending: { label: "Waiting", color: "#FFA500" }, approved: { label: "Done", color: "#00AA00" }, rejected: { label: "Denied", color: "#FF0000" } // Drop any key and TypeScript throws an error immediately. };

All three keys must be present. That is the whole contract.

Key difference

Record guarantees exhaustiveness. Define Record<"a" | "b" | "c", T> and TypeScript forces all three keys at compile time. An index signature like { [key: string]: T } accepts any string with no enforcement at all. Record makes contracts explicit. Index signatures offer flexibility.

When to use

  • Enum-like configs: role names to permissions, status codes to messages, feature flags to booleans.
  • Lookup tables: user IDs to profiles, currency codes to exchange rates.
  • State machines: each state mapped to its allowed transitions or handler functions.
  • Skip Record when keys are truly dynamic or unbounded. An index signature or Map fits better there.

How the compiler handles this

TypeScript treats Record<K, T> as shorthand for { [P in K]: T }. The compiler iterates through each member of union K and creates a required property. Omit any key and it reports an error. At runtime there is zero overhead. Record compiles to a plain JavaScript object.

Common mistakes

Mistake 1: Forgetting a key after extending the union

The most common one I see after code reviews: adding a new value to the union but forgetting to update the config object.

typescript
type Permission = "read" | "write" | "delete" | "admin"; type PermissionConfig = Record<Permission, boolean>; // TypeScript error: property 'admin' is missing const config: PermissionConfig = { read: true, write: true, delete: true, }; // Fix: add all keys const configFixed: PermissionConfig = { read: true, write: true, delete: true, admin: false, };

Mistake 2: Using Record when keys should be optional

typescript
type Feature = "darkMode" | "analytics" | "beta"; // Record requires all keys, so this fails: const flags: Record<Feature, boolean> = { darkMode: true, analytics: true, // Error: missing "beta" }; // Fix: wrap with Partial const flagsFixed: Partial<Record<Feature, boolean>> = { darkMode: true, analytics: true, // Valid now };

Mistake 3: Using Record<string, T> when keys are actually known

typescript
// This is just an index signature under a different name type D = Record<string, number>; // Same as { [key: string]: number } // When keys are known, be explicit type Currency = "USD" | "EUR" | "GBP"; const rates: Record<Currency, number> = { USD: 1.0, EUR: 0.92, GBP: 0.87, // TypeScript forces all three };

Real-world usage

  • React: component registries Record<ComponentName, ComponentType>
  • Redux: action type to handler mappings Record<ActionType, ActionCreator>
  • Express: HTTP method handlers Record<"GET" | "POST" | "DELETE", RequestHandler>
  • Testing: mock data factories Record<UserRole, MockUser>

Follow-up questions

Q: What is the difference between Record<K, T> and { [P in K]: T }?
A: They produce identical types. Record is syntactic sugar for the mapped type. Use Record for simple key-value contracts. Write the mapped type directly when you need per-key conditional logic, like { [P in K]: P extends "admin" ? AdminConfig : UserConfig }.

Q: How do you make some Record keys optional?
A: Use Partial<Record<K, T>> to make all keys optional. Or use Record<K, T | undefined> if you want every key present but allow undefined values.

Q: When would you pick Map<string, T> over Record<string, T>?
A: Record works best for static, typed configurations that compile to plain objects. Map handles dynamic, unbounded keys and has built-in iteration helpers. For config objects use Record. For caches or user-generated key-value pairs, Map fits better.

Q: What happens if you add an extra key at runtime?
A: JavaScript allows it. Record only enforces the contract at compile time. TypeScript flags config.newKey = "value" as a type error, but the runtime assignment still executes.

Examples

Role permissions map

typescript
type UserRoles = "admin" | "user" | "guest"; type RolePermissions = Record<UserRoles, string[]>; const rolePermissions: RolePermissions = { admin: ["create", "edit", "delete"], user: ["view", "edit"], guest: ["view"], }; // TypeScript knows this is string[], not string[] | undefined console.log(rolePermissions["admin"]); // ["create", "edit", "delete"]

Every key from UserRoles is required. Add "moderator" to the union and TypeScript immediately asks for a moderator property in the object.

Express route handlers

typescript
import { Request, Response } from "express"; type RouteHandlers = Record< "GET" | "POST" | "DELETE", (req: Request, res: Response) => void >; const handlers: RouteHandlers = { GET: (req, res) => res.json({ data: [] }), POST: (req, res) => res.status(201).json({}), DELETE: (req, res) => res.status(204).send(), };

Adding a new HTTP method to the union means you must implement its handler before the code compiles. The type contract enforces complete coverage.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?