A Comprehensive Guide to Understanding TypeScript Record Type

Share this article

A Comprehensive Guide to Understanding TypeScript Record Type

TypeScript’s Record type simplifies managing object structures with consistent value types. This guide covers the essentials of Record, including its definition, syntax, and how it differs from other types like tuples. We’ll learn how to define and use Record in practical scenarios such as enforcing exhaustive case handling and mapping enums. Additionally, we’ll explore advanced uses by combining Record with utility types like Partial, Pick, and Readonly.

Introduction

The Record type is a utility type that allows us to create an object type with specified keys and a uniform value type. This type is particularly useful for defining mappings and ensuring that all values in an object conform to a single type.

Definition of Record Type

The official definition from the TypeScript documentation is:

Record<Keys, Type>

Here:

  • Keys represent the set of keys in the record, which can be a union of string literals or a type derived from a union.
  • Type is the type of the values associated with those keys.

For example, Record<string, number> defines an object where every key is a string and every value is a number. This type ensures that all properties of the object have the same value type, but the keys can be varied.

Comparison Between a Record and a Tuple

Both Record and tuples are used to handle collections of data, but they serve different purposes. Even as they store multiple values, they differ in structure and usage. A Record has named properties with a fixed type, whereas a tuple is an ordered list of elements identified by their position. Here’s a simple comparison:

  • Record. Creates an object type where all values have the same type, but the keys can be flexible. This is useful for mapping keys to values and ensuring that all keys adhere to a specific type.
  • Tuple. Defines an array with a fixed number of elements, where each element can have a different type. Tuples are used when we need a fixed-size collection with specific types for each position.

For example, consider the following.

Here’s a Record type, which maps string keys to number values:

type AgeMap = Record<string, number>;

A tuple type represents an array with a string (name) and a number (age) in a fixed position:

type Person = [string, number];

Basic Usage of Record Type

The Record type provides a simple and efficient way to map keys to values. It’s particularly useful when we need to define objects with specific key–value pairs where the keys are of a particular type, and the values are of another type.

Here are some basic ways to use the Record type to define and create structured data.

Defining a Record

To define a Record, we specify the types for the keys and values. The example below defines an object where each key is a string, and each value is also a string. This could be used for a generic map of user data:

type User = Record<string, string>;

Creating a Record Type Example

Some websites have various subdomains. Let’s assume each of these subdomains requires some level of admin access and create a Record type for storing different admin roles and their corresponding access levels. Here, UserRoles and UserStatus are Record types where the keys are specific string literals (admin, blogAdmin, docsAdmin, active, inactive, suspended), and the values are strings that describe each role and status.

First, we define a Record type UserRoles with specific admin roles as keys and their descriptions as values. The UserRoles type ensures that any object of this type will have keys admin, blogAdmin, and docsAdmin, with string values describing each role. The roles object adheres to this type by providing descriptions for each admin role:

type UserRoles = Record<'admin' | 'blogAdmin' | 'docsAdmin', string>;

const roles: UserRoles = {
  admin: 'General Administrator with access to all areas.',
  blogAdmin: 'Administrator with access to blog content.',
  docsAdmin: 'Administrator with access to documentation.'
};

Next, we define a Record type UserStatus with specific statuses as keys and their descriptions as values. The UserStatus type ensures that any object of this type will have keys active, inactive, and suspended, with string values describing each status. The userStatus object adheres to this type by providing descriptions for each status:

type UserStatus = Record<'active' | 'inactive' | 'suspended', string>;

const userStatus: UserStatus = {
  active: 'User is currently active and can use all features.',
  inactive: 'User is currently inactive and cannot access their account.',
  suspended: 'User account is suspended due to policy violations.'
};

By creating Record types in this way, we ensure that the admin roles and user statuses are well defined and consistent throughout the application.

Practical Use Cases of Record Type

In this section, we’ll review several practical use cases of the Record type to demonstrate its versatility and effectiveness in different scenarios.

Use Case 1: Enforcing Exhaustive Case Handling

Using Record to define a mapping between case values and messages allows us to handle each possible case explicitly. This ensures that all cases are covered and that any missing cases will result in compile-time errors.

In the example below, statusMessages is a Record where the keys are specific Status values ('pending', 'completed', 'failed'), and each key maps to a corresponding message. The getStatusMessage function uses this record to return the appropriate message based on the status parameter. This approach guarantees that all statuses are handled correctly and consistently.

Example:

type Status = 'pending' | 'completed' | 'failed';

interface StatusInfo {
  message: string;
  severity: 'low' | 'medium' | 'high';
  retryable: boolean;
}

const statusMessages: Record<Status, StatusInfo> = {
  pending: {
    message: 'Your request is pending.',
    severity: 'medium',
    retryable: true,
  },
  completed: {
    message: 'Your request has been completed.',
    severity: 'low',
    retryable: false,
  },
  failed: {
    message: 'Your request has failed.',
    severity: 'high',
    retryable: true,
  },
};

function getStatusMessage(status: Status): string {
  const info = statusMessages[status];
  return `${info.message} Severity: ${info.severity}, Retryable: ${info.retryable}`;
}

//Cases where the request was successful.
console.log(getStatusMessage('completed')); // Your request has been completed. Severity: low, Retryable: false

Use Case 2: Enforcing Type Checking in Applications Using Generics

Generics in TypeScript allow for flexible and reusable code. When combined with Record, generics can help enforce type checking and ensure that objects conform to specific structures.

By using generics with Record, we can create functions or utilities that generate objects with a specific set of keys and a consistent value type. This approach enhances type safety and reusability in our codebase.

In the example below, the createRecord function takes an array of keys and a value, and it returns a Record where each key maps to the provided value. This function uses generics (K for keys and T for value type) to ensure that the resulting Record has the correct structure.

Example:

function createRecord<K extends string, T>(keys: K[], value: T): Record<K, T> {
  const record: Partial<Record<K, T>> = {};
  keys.forEach(key => record[key] = value);
  return record as Record<K, T>;
}

interface RoleInfo {
  description: string;
  permissions: string[];
}

const userRoles = createRecord(['admin', 'editor', 'viewer'], {
  description: 'Default role',
  permissions: ['read'],
});

console.log(userRoles);
/*
//Output:
{
  admin: { description: 'Default role', permissions: ['read'] },
  editor: { description: 'Default role', permissions: ['read'] },
  viewer: { description: 'Default role', permissions: ['read'] }
}
*/

Use Case 3: Mapping Enums to Data

Using Record to map enums to data allows us to create a lookup table where each enum value is associated with specific information. This is particularly useful for scenarios like configuring settings based on enum values.

In this example, colorHex is a Record that maps each Color enum value to its corresponding hexadecimal color code. This approach provides a clear and type-safe way to handle color-related data based on enum values.

Example:

enum Color {
  Red = 'RED',
  Green = 'GREEN',
  Blue = 'BLUE',
  Yellow = 'YELLOW'
}

interface ColorInfo {
  hex: string;
  rgb: string;
  complementary: string;
}

const colorHex: Record<Color, ColorInfo> = {
  [Color.Red]: {
    hex: '#FF0000',
    rgb: 'rgb(255, 0, 0)',
    complementary: '#00FFFF',
  },
  [Color.Green]: {
    hex: '#00FF00',
    rgb: 'rgb(0, 255, 0)',
    complementary: '#FF00FF',
  },
  [Color.Blue]: {
    hex: '#0000FF',
    rgb: 'rgb(0, 0, 255)',
    complementary: '#FFFF00',
  },
  [Color.Yellow]: {
    hex: '#FFFF00',
    rgb: 'rgb(255, 255, 0)',
    complementary: '#0000FF',
  },
};

console.log(colorHex[Color.Green]); //Output: { hex: '#00FF00', rgb: 'rgb(0, 255, 0)', complementary: '#FF00FF' }

Use Case 4: Creating Lookup Tables

A lookup table using Record helps in mapping keys (such as identifiers, names) to specific values (such as descriptions, codes). This can be useful for various applications, including configurations, translations, and many other things.

Here, countryCode is a Record that maps country codes to their respective country names, population, capitals and continents. This lookup table allows for quick and type-safe retrieval of country names and populations based on country codes.

Example:

type CountryCode = "US" | "CA" | "MX" | "JP";

interface CountryInfo {
  name: string;
  population: number;
  capital: string;
  continent: string;
}

const countryLookup: Record<CountryCode, CountryInfo> = {
  US: {
    name: "United States",
    population: 331000000,
    capital: "Washington D.C.",
    continent: "North America",
  },
  CA: {
    name: "Canada",
    population: 37700000,
    capital: "Ottawa",
    continent: "North America",
  },
  MX: {
    name: "Mexico",
    population: 128000000,
    capital: "Mexico City",
    continent: "North America",
  },
  JP: {
    name: "Japan",
    population: 126300000,
    capital: "Tokyo",
    continent: "Asia",
  },
};

console.log(countryLookup.US);
/*
//Output:
{
  name: "United States",
  population: 331000000,
  capital: "Washington D.C.",
  continent: "North America"
}
*/

console.log(countryLookup.US.population);//Output: 331000000,

Iterating Over Record Types

Iterating over Record types is important for accessing and manipulating the data within data structures. Let’s create a sample data and show various methods on how we can iterate over the TypeScript Record types.

Sample Data:

interface Course {
  professor: string;
  credits: number;
  students: string[];
}

interface Courses {
  [key: string]: Course;
}

const courses: Courses = {
  Math101: {
    professor: "Dr. Eze",
    credits: 3,
    students: ["Emmanuel", "Bob", "Charlie"],
  },
  History201: {
    professor: "Dr. Jones",
    credits: 4,
    students: ["Dave", "Eve"],
  },
};

Using forEach. To use forEach with a Record, convert it to an array of key-value pairs:

Object.entries(courses).forEach(([key, value]) => {
  console.log(`${key}: ${value.professor}, ${value.credits}`);
  value.students.forEach(student => {
    console.log(`Student: ${student}`);
  });
});

/*
//Output: 
Math101: Dr. Eze, 3
Student: Emmanuel
Student: Bob
Student: Charlie
History201: Dr. Jones, 4
Student: Dave
Student: Eve
*/

Using for...in. The for...in loop iterates over the keys of a Record:

for (const key in courses) {
  if (courses.hasOwnProperty(key)) {
    const course = courses[key];
    console.log(`${key}: ${course.professor}, ${course.credits}`);
    course.students.forEach(student => {
      console.log(`Student: ${student}`);
    });
  }
}

/*
//Output: 
Math101: Dr. Eze, 3
Student: Emmanuel
Student: Bob
Student: Charlie
History201: Dr. Jones, 4
Student: Dave
Student: Eve
*/

Using Object.keys(). Object.keys() returns an array of the Record’s keys:

Object.keys(courses).forEach((key) => {
  const course = courses[key];
  console.log(`${key}: ${course.professor}, ${course.credits}`);
  course.students.forEach(student => {
    console.log(`Student: ${student}`);
  });
});

/*
//Output: 
Math101: Dr. Eze, 3
Student: Emmanuel
Student: Bob
Student: Charlie
History201: Dr. Jones, 4
Student: Dave
Student: Eve
*/

Using Object.values(). Object.values() returns an array of the Record’s values:

Object.values(courses).forEach((course) => {
  console.log(`${course.professor}, ${course.credits}`);
  course.students.forEach(student => {
    console.log(`Student: ${student}`);
  });
});

/*
//Output: 
Dr. Eze, 3
Student: Emmanuel
Student: Bob
Student: Charlie
Dr. Jones, 4
Student: Dave
Student: Eve
*/

Advanced Usage and Utility Types with Record

The Record type can be combined with other utility types to achieve greater flexibility and type safety. This section exposes advanced usage patterns, demonstrating how Record can work with utility types like Pick, Readonly, and Partial.

Combining Record with Pick for Selective Type Mapping

The Pick utility type allows us to create a new type by selecting specific properties from an existing type. This is useful when we want to work with only a subset of properties from a larger type.

Here, we created a new type SelectedProductInfo by picking only the name and price properties from the ProductInfo interface, and then using Record to map different products to this new type:

interface ProductInfo {
  name: string;
  price: number;
  category: string;
}
type SelectedProductInfo = Pick<ProductInfo, "name" | "price">;
type Product = 'Laptop' | 'Smartphone' | 'Tablet';

const products: Record<Product, SelectedProductInfo> = {
  "Laptop": { name: "Dell XPS 15", price: 1500 },
  "Smartphone": { name: "iPhone 12", price: 999 },
  "Tablet": { name: "iPad Pro", price: 799 }
};

Combining Record with Readonly for Immutable Properties

The Readonly utility type ensures that properties can’t be modified after they’re set. This is useful for creating immutable data structures.

The ReadonlyProductInfo type in the example below makes all properties of ProductInfo immutable, ensuring that the details of each product can’t be changed once they’re defined:

type ReadonlyProductInfo = Readonly<ProductInfo>;
const readonlyProducts: Record<Product, ReadonlyProductInfo> = {
  "Laptop": { name: "Dell XPS 15", price: 1500, category: "Electronics" },
  "Smartphone": { name: "iPhone 12", price: 999, category: "Electronics" },
  "Tablet": { name: "iPad Pro", price: 799, category: "Electronics" }
};

Combining Record with Partial for Optional Properties

The Partial utility type makes all properties of a type optional. This is useful for scenarios where not all properties might be known or required at the same time.

Here, the PartialProductInfo type allows us to create products with some or none of the properties defined in ProductInfo, providing flexibility in how product information is specified:

type PartialProductInfo = Partial<ProductInfo>;
const partialProducts: Record<Product, PartialProductInfo> = {
  "Laptop": { name: "Dell XPS 15" },
  "Smartphone": { price: 999 },
  "Tablet": {}
};

Combining Record with Record for Nested Mapping

Another advanced usage involves combining Record types to create nested mappings, which can be particularly useful for managing complex data structures.

In this example, storeInventory uses nested Record types to map departments to their respective products and details, demonstrating how Record can be combined for more complex data management:

type Department = 'Electronics' | 'Furniture';
type ProductDetails = Record<Product, ProductInfo>;

const storeInventory: Record<Department, ProductDetails> = {
  "Electronics": {
    "Laptop": { name: "Dell XPS 15", price: 1500, category: "Electronics" },
    "Smartphone": { name: "iPhone 12", price: 999, category: "Electronics" },
    "Tablet": { name: "iPad Pro", price: 799, category: "Electronics" }
  },
  "Furniture": {
    "Chair": { name: "Office Chair", price: 200, category: "Furniture" },
    "Table": { name: "Dining Table", price: 500, category: "Furniture" },
    "Sofa": { name: "Living Room Sofa", price: 800, category: "Furniture" }
  }
};

Conclusion

The Record type is a versatile tool for managing and structuring object types as it allows us to define clear mappings between keys and values, ensuring type safety and consistency in our code.

For more detailed information, refer to the TypeScript documentation and review other additional resources like Total TypeScript and TypeScript Tutorial to deepen your understanding of TypeScript’s Record type system.

Emmanuel OnyeyaforoEmmanuel Onyeyaforo
View Author

Emmanuel is a passionate software developer who finds joy in both writing and problem-solving.

TypeScript
Share this article
Read Next
Get the freshest news and resources for developers, designers and digital creators in your inbox each week