Utility Type: Record<K, T> for Dictionary Types
Introduction
Working with dictionaries (maps from keys to values) is a common task in any non-trivial application: configuration objects, lookup tables, caches, and keyed collections all rely on mapping keys to values. In TypeScript, the utility type Record<K, T> provides a concise way to model such structures with strong typing. However, many intermediate developers underuse or misuse Record, leading to overly permissive types, structural bugs, or duplication in type definitions.
In this tutorial you will learn how Record<K, T> works under the hood, how to combine it with other TypeScript features (mapped types, conditional types, infer, unions, and intersections), and when to choose Record over interfaces or index signatures. We'll cover common patterns: restricted keys, optional/partial records, nested records, safe lookups, performance considerations, and real-world examples. You will also get hands-on code snippets and step-by-step instructions to integrate Record in your codebase safely.
By the end of this article you'll be able to:
- Use Record with literal unions and mapped types to express exact key sets
- Compose Record with utility types (Partial, Readonly) and advanced mapped types
- Avoid pitfalls such as widening literal keys or unsafe indexing
- Build type-safe dictionary utilities for common app patterns
Let's dive into Record and see how it can make your TypeScript code more explicit and reliable.
Background & Context
Record<K, T> is a built-in TypeScript utility type that maps a set of keys K to a value type T. It is effectively a shorthand for a mapped type over keys K. For many use cases Record is clearer than a plain index signature (e.g., { [key: string]: T }) because it allows you to restrict keys to unions of literal types or to existing key sets. Properly used, Record reduces duplication and expresses intent: "these keys exist and have this type." Understanding how Record composes with union types, mapped types, and conditional types is important when modeling more complex shapes like nested dictionaries, sparse maps, or keyed API response shapes.
This article assumes you're comfortable with TypeScript basics and have experience with utility types like Partial and Readonly. We'll also touch on related concepts like extracting function parameters or return types to build dictionary-based registries—if you're not familiar with those, check out the guide on Utility Type: ReturnType
Key Takeaways
- Record<K, T> creates mapped types and is ideal for fixed-key dictionaries.
- Prefer literal unions or keyof to restrict Record keys; avoid loose index signatures when possible.
- Combine Record with Partial, Readonly, and mapped type modifiers for flexible shapes.
- Use safe access helpers and type guards to avoid undefined and runtime errors.
- Leverage advanced features (infer, conditional and recursive types) when building generic dictionary utilities.
Prerequisites & Setup
To follow the code examples in this tutorial you'll need:
- Node.js and npm (optional, for running TypeScript playground or local environment)
- TypeScript 4.x or newer (some examples use newer mapped type capabilities)
- An editor with TypeScript support (VS Code recommended)
If you want to run snippets locally: npm install -D typescript ts-node, and create a tsconfig.json targeting ES2019+ for the best developer experience. You can also paste code into the TypeScript Playground. Familiarity with mapped types, conditional types, and basic generics is assumed. If you'd like to refresh on mapped-type modifiers, see our guide on Advanced Mapped Types: Modifiers (+/- readonly, ?).
Main Tutorial Sections
1) Basic Record<K, T> Syntax and Simple Examples
Record<K, T> takes two generics: K — a union of keys (usually string | number | symbol or literal unions), and T — the value type. Here's a minimal example:
type Role = 'admin' | 'editor' | 'viewer'; const defaults: Record<Role, number> = { admin: 5, editor: 3, viewer: 1, }; // access is fully typed const adminQuota: number = defaults['admin'];
This enforces that all Role keys are present. If you omit one, TypeScript flags an error. Compare this to an index signature ({ [key: string]: number }
) which can't enforce a fixed set of keys.
2) When to Prefer Record vs Interfaces and Index Signatures
Use Record when you want a mapping over a known set of keys (literal union or keyof). Use an interface when modeling objects with methods or heterogeneous properties. Use index signatures for truly unknown keys. For example, when keys are a union of literals, Record expresses intent clearly:
type Locale = 'en' | 'es' | 'fr'; type Translations = Record<Locale, string>;
If keys are arbitrary strings, an index signature may be appropriate: { [k: string]: string }
. For a comparison of interfaces and type aliases, see Differentiating Between Interfaces and Type Aliases in TypeScript.
3) Partial and Optional Records
Sometimes not every key is present — use Partial<Record<K, T>> to allow missing keys without losing key constraints:
type ConfigKeys = 'host' | 'port' | 'tls'; const partialConfig: Partial<Record<ConfigKeys, string | number | boolean>> = { host: 'localhost', };
Partial wraps the mapped Record type and makes each property optional. This is useful for incremental configuration or when merging defaults with user overrides.
4) Readonly and Immutability with Record
To mark a dictionary as immutable, combine Readonly with Record:
const readonlyMapping: Readonly<Record<'a' | 'b', number>> = { a: 1, b: 2 }; // readonlyMapping.a = 10; // Error — cannot assign to 'a'
This is safer for caching scenarios. For more on modifiers like +/- readonly, check the deep dive on Advanced Mapped Types: Modifiers (+/- readonly, ?).
5) Narrow Keys with keyof and Existing Types
A powerful pattern is using keyof to build Record keys from existing types. For instance, converting an interface's keys into a dictionary type:
interface User { id: string; name: string; age: number } type UserBooleans = Record<keyof User, boolean>; // Equivalent to: { id: boolean; name: boolean; age: boolean }
This is handy for building field-level flags (changed/dirty maps), and it ties the dictionary directly to the shape of another type so refactors stay consistent.
6) Indexed Access & Safe Lookups
Indexing into a Record with an arbitrary string can be unsafe. Prefer key narrowing or helper functions:
type Color = 'red' | 'green' | 'blue'; const palette: Record<Color, string> = { red: '#f00', green: '#0f0', blue: '#00f' }; function getColor(k: string): string | undefined { if ((['red','green','blue'] as const).includes(k as Color)) { return palette[k as Color]; } return undefined; }
Alternatively, use a type-safe wrapper:
function getColorSafe(key: Color) { return palette[key]; }
Using literal unions prevents accidental passes of arbitrary strings. For patterns that derive keys from runtime data, extract types from the runtime shape and keep them in sync.
7) Nested Records and Deep Transformations
Record composes well for nested dictionaries. For example, a localization dictionary keyed by locale and message id:
type Locale = 'en' | 'es'; type MsgKey = 'welcome' | 'goodbye'; type LocaleMessages = Record<Locale, Record<MsgKey, string>>; const messages: LocaleMessages = { en: { welcome: 'Welcome', goodbye: 'Goodbye' }, es: { welcome: 'Bienvenido', goodbye: 'Adiós' }, };
For deeper transformations that manipulate nested Records generically, recursive mapped types can help; see our article on Recursive Mapped Types for Deep Transformations in TypeScript for strategies and pitfalls.
8) Dynamic Key Sets: Narrowing from Arrays and Objects
When keys come from an array or object, prefer a const assertion so TypeScript infers literal unions instead of widening to string[]. Example:
const EVENTS = ['click', 'hover', 'submit'] as const; type EventName = (typeof EVENTS)[number]; // 'click' | 'hover' | 'submit' type Handlers = Record<EventName, () => void>; const handlers: Handlers = { click: () => console.log('click'), hover: () => console.log('hover'), submit: () => console.log('submit'), };
If you omit as const, you'll lose the literal union and Record will accept less precise keys. For more on extracting elements from arrays with infer-like patterns, see Using infer with Arrays in Conditional Types — Practical Guide.
9) Building a Type-Safe Registry of Functions
Record pairs nicely with utilities like ReturnType or Parameters when building registries of functions. For example, a command registry keyed by name, with typed function signatures:
type Commands = { ping: (url: string) => Promise<boolean>; sum: (a: number, b: number) => number; }; type CommandHandlers = Record<keyof Commands, Commands[keyof Commands]>; const registry: Commands = { ping: async (url) => true, sum: (a,b) => a + b, }; // Use helpers to extract types: type PingParams = Parameters<Commands['ping']>; // [string] type SumReturn = ReturnType<Commands['sum']>; // number
Combining Record with Parameters
10) Advanced Key Remapping and Conditional Records
You can remap keys or transform value types across a Record using advanced mapped types and conditional types. For example, mapping a Record<T, U> into another shape with optional fields or key renaming requires key remapping logic. Review Advanced Mapped Types: Key Remapping with Conditional Types to see how to rename keys or filter them programmatically. A small example:
type PrefixKeys<T, P extends string> = { [K in keyof T as `${P & string}_${K & string}`]: T[K] }; type Base = { a: number; b: string }; type Prefixed = PrefixKeys<Base, 'x'>; // { x_a: number; x_b: string }
This is useful when building namespaced config maps or when merging dictionaries with different key domains.
Advanced Techniques
Once you're comfortable with Record, you can use conditional and recursive conditional types to produce more dynamic dictionaries: union-to-intersection transformations, distribution over union keys, or extracting nested keys. For example, you can build utility types that flip a Record<LiteralUnion, T> into an indexed lookup type for inversion operations. Pairing infer in conditional types with object/array extraction helps construct type-level transforms; see Using infer
with Objects in Conditional Types — Practical Guide and Using infer with Functions in Conditional Types for patterns. Also, when building dictionaries that may require recursion (e.g., arbitrarily nested translation trees), refer to Recursive Conditional Types for Complex Type Manipulations to avoid infinite recursion and ensure performance.
Performance tip: complex conditional or recursive types can slow down the compiler. Keep type transformations as shallow as possible, cache intermediate types via type aliases, and prefer explicit key unions when practical.
Best Practices & Common Pitfalls
Dos:
- Use literal unions for K when possible to get precise typing and autocomplete.
- Combine Record with Partial/Readonly to express optional and immutable dictionaries.
- Prefer keyof and mapped types to tie records to existing interfaces, so refactors are safer.
- Create small helper functions for safe lookup instead of repeatedly casting (as any).
Don'ts:
- Don’t use plain index signatures if you can use Record with a restricted key set. Index signatures are too permissive and lose intent.
- Avoid overcomplicating types with wildly recursive conditional types unless necessary; they can hurt IDE responsiveness.
- Do not rely on string concatenation to construct keys at runtime without corresponding type-level guarantees — mismatch errors are common.
Troubleshooting tips:
- If Record loses literal union inference, check whether you need a const assertion on the source array/object.
- If the compiler becomes slow, break up complex utility types into smaller intermediate types.
- When you need to rename or remap keys, validate your mapping by creating test types and using IDE tooling to inspect inferred shapes. For help on remapping strategies, the key-remapping guide is useful: Advanced Mapped Types: Key Remapping with Conditional Types.
Real-World Applications
Record is extremely useful in many real-world scenarios:
- Feature flags: Record<FeatureName, boolean> to model toggles per environment.
- Localization: nested Record<Locale, Record<MessageKey, string>> for translation bundles.
- Caches and memoization: typed caches like Record<string, CachedResult> or Record<Id, Resource> with safe lookups.
- Event handlers: Record<EventName, (ev: Event) => void> with compile-time guarantees of handlers.
For registries of classes or functions where you need to implement interfaces with classes, look at our guide on Implementing Interfaces with Classes and tie Record-based registries to those implementations.
Conclusion & Next Steps
Record<K, T> is a compact, expressive tool for modeling dictionaries in TypeScript. Use it to create clear, intention-revealing types for keyed data, combine it with other utility types for flexibility, and rely on mapped/conditional types when building more advanced transformations. Next, practice converting a few existing loose object maps into Records with literal unions and add safe accessor helpers. To expand your knowledge, explore conditional/infer patterns and recursive mapped types through the linked resources.
Enhanced FAQ
Q: What exactly is Record<K, T> under the hood? A: Record<K, T> is a mapped type. It expands roughly to: { [P in K]: T } where K is a union of keys. This means each key in K becomes a required property with value type T. Because it's a mapped type, you can combine it with other mapped-type modifiers like readonly or optional via Partial/Required.
Q: Can K be any type? A: K should be a subtype of string | number | symbol (commonly literal unions or keyof results). If you pass a broad type like string, TypeScript will create a string index signature equivalent, which loses key-specific constraints.
Q: How do I model missing keys in a Record? A: Use Partial<Record<K, T>> to make the properties optional. This is useful for partial configs or patched updates. Alternatively, use Record<K, T | undefined> if you want the keys present but possibly undefined.
Q: What’s the difference between Record and an index signature like { [key: string]: T }? A: Record can enforce a specific set of keys when K is a literal union. An index signature accepts arbitrary keys and can't enforce presence for particular literal keys. Records are better when you have known keys; index signatures are for truly dynamic keys.
Q: How do I infer Record keys from runtime arrays or objects? A: Use a const assertion on the source array/object: const KEYS = ['a','b'] as const; then type Keys = (typeof KEYS)[number]; This yields a literal union suitable for Record. See the section "Dynamic Key Sets" above for an example and the guide on arrays with infer patterns Using infer with Arrays in Conditional Types — Practical Guide.
Q: How can I build a typed registry of functions with Record?
A: Define an interface mapping names to function types and then use Record or directly reference that interface to create the registry. Use utility types like Parameters and ReturnType to extract shapes. See the command registry example in the tutorial and the guides on Parameters
Q: Are there performance concerns with complex Record-based types? A: Yes — extremely complex conditional or recursive types referencing large structures can slow down the TypeScript compiler and IDE type checking. Keep types as shallow as possible, break large transforms into smaller aliases, and avoid overly recursive constructs unless necessary. For deep transformations, compare strategies in Recursive Mapped Types for Deep Transformations in TypeScript.
Q: Can I remap keys or transform a Record into another shape?
A: Yes. Use key remapping syntax in mapped types (the as
clause) and conditional types to filter or rename keys. For examples and patterns, refer to Advanced Mapped Types: Key Remapping with Conditional Types.
Q: When should I use intersections or unions with Record? A: Use unions when the value type could be multiple shapes (e.g., Record<K, A | B>), and use intersections when composing behaviors (rare for values but common in object composition). Intersection keys on objects combine properties and are useful for merging typed dictionaries. See our guides on Union Types and Intersection Types for more context.
Q: Any resources to learn advanced infer/conditional patterns used with Record?
A: Yes — to get more advanced, explore the guides on using infer with objects and functions: Using infer
with Objects in Conditional Types — Practical Guide and Using infer with Functions in Conditional Types, which illustrate techniques for extracting keys, values, and building flexible dictionary utilities.
If you have a sample shape or a codebase you want to harden with Record, paste the example and I can help convert it step-by-step into a type-safe implementation.