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 areKeys
and whose property values areType
. 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 typeRecord<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 typeRecord<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 theProperties
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