DEV Community

Cover image for Level up your TypeScript with Record types
Matt Angelosanto for LogRocket

Posted on • Originally published at blog.logrocket.com

Level up your TypeScript with Record types

Written by Matteo Di Pirro✏️

In computer science, a record is a data structure holding a collection of fields, possibly with different types. In TypeScript, the Record type simply allows us to define dictionaries, also referred to as key-value pairs, with a fixed type for the keys and a fixed type for the values.

In other words, the Record type lets us define the type of a dictionary; that is, the names and types of its keys. In this article, we’ll explore the Record type in TypeScript to better understand what it is and how it works. We’ll also investigate how to use it to handle enumeration, as well as how to use it with generics to understand the properties of the returned value when writing reusable code.

Jump ahead:

What’s the difference between a record and a tuple?

The Record type in TypeScript may initially seem somewhat counterintuitive. In TypeScript, records have a fixed number of members (i.e., a fixed number of fields), and the members are usually identified by name. This is the primary way that records differ from tuples.

Tuples are groups of ordered elements, where the fields are identified by their position in the tuple definition. Fields in records, on the other hand, have names. Their position does not matter since we can use their name to reference them.

That being said, at first, the Record type in TypeScript might look unfamiliar. Here’s the official definition from the docs:

Record<Keys, Type> constructs an object type whose property keys are Keys and whose property values are Type. This utility can be used to map the properties of a type to another type.”

Let’s take a look at an example to better understand how we can use the TypeScript Record type.

Implementing the TypeScript Record type

The power of TypeScript’s Record type is that we can use it to model dictionaries with a fixed number of keys. For example, we could use the the Record type to create a model for university courses:

type Course = "Computer Science" | "Mathematics" | "Literature"

interface CourseInfo {
    professor: string
    cfu: number
}

const courses: Record<Course, CourseInfo> = {
    "Computer Science": {
                professor: "Mary Jane",
                cfu: 12
    },
    "Mathematics": {
                professor: "John Doe",
                cfu: 12
    },
    "Literature": {
                professor: "Frank Purple",
                cfu: 12
    }
}
Enter fullscreen mode Exit fullscreen mode

In this example, we defined a type named Course that will list the names of classes and a type named CourseInfo that will hold some general details about the courses. Then, we used a Record type to match each Course with its CourseInfo.

So far, so good — it all looks like quite a simple dictionary. The real strength of the Record type is that TypeScript will detect whether we missed a Course.

Identifying missing properties

Let’s say we didn’t include an entry for Literature. We’d get the following error at compile time:

“Property Literature is missing in type { "Computer Science": { professor: string; cfu: number; }; Mathematics: { professor: string; cfu: number; }; } but required in type Record<Course, CourseInfo>

In this example, TypeScript is clearly telling us that Literature is missing.

Identifying undefined properties

TypeScript will also detect if we add entries for values that are not defined in Course. Let’s say we added another entry in Course for a History class. Since we didn’t include History as a Course type, we’d get the following compilation error:

“Object literal may only specify known properties, and "History" does not exist in type Record<Course, CourseInfo>

Accessing Record data

We can access data related to each Course as we would with any other dictionary:

console.log(courses["Literature"])
Enter fullscreen mode Exit fullscreen mode

The statement above prints the following output:

{ "teacher": "Frank Purple", "cfu": 12 }
Enter fullscreen mode Exit fullscreen mode

Let's proceed to take a look at some cases where the Record type is particularly useful.

Use case 1: Enforcing exhaustive case handling

When writing modern applications, it’s often necessary to run different logic based on some discriminating value. A perfect example is the factory design pattern, where we create instances of different objects based on some input. In this scenario, handling all cases is paramount.

The simplest (and somehow naive) solution would probably be to use a switch construct to handle all the cases:

type Discriminator = 1 | 2 | 3

function factory(d: Discriminator): string {
    switch(d) {
            case 1:
            return "1"
            case 2:
                return "2"
            case 3:
                return "3"
            default:
                return "0"
    }
}
Enter fullscreen mode Exit fullscreen mode

If we add a new case to Discriminator, however, due to the default branch, TypeScript will not tell us we’ve failed to handle the new case in the factory function. Without the default branch, this would not happen; instead, TypeScript would detect that a new value had been added to Discriminator.

We can leverage the power of the Record type to fix this:

type Discriminator = 1 | 2 | 3

function factory(d: Discriminator): string {
    const factories: Record<Discriminator, () => string> = {
            1: () => "1",
            2: () => "2",
            3: () => "3"
    }
    return factories[d]()
}

console.log(factory(1))
Enter fullscreen mode Exit fullscreen mode

The new factory function simply defines a Record matching a Discriminator with a tailored initialization function, inputting no arguments and returning a string. Then, factory just gets the right function, based on the d: Discriminator, and returns a string by calling the resulting function. If we now add more elements to Discriminator, the Record type will ensure that TypeScript detects missing cases in factories.

Use case 2: Enforcing type checking in applications that use generics

Generics allow us to write code that is abstract over actual types. For example, Record<K, V> is a generic type. When we use it, we have to pick two actual types: one for the keys (K) and one for the values (V).

Generics are extremely useful in modern programming, as they enable us to write highly reusable code. The code to make HTTP calls or query a database is normally generic over the type of the returned value. This is very nice, but it comes at a cost because it makes it difficult for us to know the actual properties of the returned value.

We can solve this by leveraging the Record type:

class Result<Properties = Record<string, any>> {
    constructor(
                public readonly properties: Record<
                        keyof Properties,
                        Properties[keyof Properties]
                >
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

Result is a bit complex. In this example, we declare it as a generic type where the type parameter, Properties, defaults to Record<string, any>.

Using any here might look ugly, but it actually makes sense. As we’ll see in a moment, the Record will map property names to property values, so we can’t really know the type of the properties in advance. Furthermore, to make it as reusable as possible, we’ll have to use the most abstract type TypeScript has — any, indeed!

The constructor leverages some TypeScript syntactic sugar to define a read-only property, which we’ve aptly named properties. Notice the definition of the Record type:

  • The type of the key is keyof Properties, meaning that the keys in each object have to be the same as those defined by the Properties generic type
  • The value of each of the keys will be the value of the corresponding property of the Properties record

Now that we’ve defined our main wrapper type, we can experiment with it. The following example is very simple, but it demonstrates how we can use Result to have TypeScript check the properties of a generic type:

interface CourseInfo {
    title: string
    professor: string
    cfu: number
}

const course = new Result<Record<string, any>>({
    title: "Literature",
    professor: "Mary Jane",
    cfu: 12
})

console.log(course.properties.title)
//console.log(course.properties.students)     <- this does not compile!
Enter fullscreen mode Exit fullscreen mode

In the above code, we define a CourseInfo interface that looks similar to what we saw earlier. It simply models the basic information we’d like to store and query: the name of the class, the name of the professor, and the number of credits.

Next, we simulate the creation of a course. This is just a literal value, but you can imagine it to be the result of a database query or an HTTP call.

Notice that we can access the course properties in a type-safe manner. When we reference an existing property, such as title, it compiles and works as expected. When we attempt to access a nonexistent property, such as students, TypeScript detects that the property is missing in the CourseInfo declaration, and the call does not compile.

This is a powerful feature we can leverage in our code to ensure the values we fetch from external sources comply with our expected set of properties. Note that if course had more properties than those defined by CourseInfo, we could still access them. In other words, the following snippet would work:

// CourseInfo and Result as above

const course = new Result<Record<string, any>>({
    title: "Literature",
    professor: "Mary Jane",
    cfu: 12,
    webpage: "https://..."
})

console.log(course.properties.webpage)
Enter fullscreen mode Exit fullscreen mode

Conclusion

In this article, we explored one of TypeScript’s inbuilt types, Record<K, V>. We looked at basic usage for the Record type and investigated how it behaves. Then, we examined two prominent use cases for the Record type.

In the first use case, we investigated how we can use the Record type to ensure that we handle the cases of an enumeration. In the second use case, we explored how we can enforce type checking on the properties of an arbitrary object in an application with generic types.

The Record type is really powerful. Some of its use cases are rather niche, but it provides real value for our application’s code.


Get set up with LogRocket's modern Typescript error tracking in minutes:

1.Visit https://logrocket.com/signup/ to get an app ID.
2.Install LogRocket via NPM or script tag. LogRocket.init() must be called client-side, not server-side.

NPM:

$ npm i --save logrocket 

// Code:

import LogRocket from 'logrocket'; 
LogRocket.init('app/id');
Enter fullscreen mode Exit fullscreen mode

Script Tag:

Add to your HTML:

<script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script>
<script>window.LogRocket && window.LogRocket.init('app/id');</script>
Enter fullscreen mode Exit fullscreen mode

3.(Optional) Install plugins for deeper integrations with your stack:

  • Redux middleware
  • ngrx middleware
  • Vuex plugin

Get started now

Top comments (0)