Level up your TypeScript with Record types

Level Up TypeScript Record Types

The Record type in TypeScript is used to define dictionaries or key-value pairs with a fixed type for the keys and a fixed type for the values. It allows us to define the type of a dictionary, including the names and types of its keys. This can be useful when handling enumeration or when writing reusable code with generics.

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
    }
}

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"])

The statement above prints the following output:

{ "teacher": "Frank Purple", "cfu": 12 }

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"
    }
}

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))

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]
                >
    ) {}
}

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!

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)

Conclusion

This article showed you how to use one of TypeScript's built-in types, Record<K, V>. You learned the basics of the Record type and how it works. You also saw two examples of how the Record type can help you in your code.

In the first example, you used the Record type to make sure that you covered all the cases of an enum. In the second example, you used the Record type to check the types of the properties of any object in a generic way.

The Record type is very useful. It can do some things that are not very common, but it can also improve your code quality.

No comments:

Post a Comment