Objects & Records
Objects and Records are two of the most common data structures we can manipulate in Type-level TypeScript. They will be the bread and butter of our type-level algorithms, so understanding how they work is essential. I'm sure you've already used objects and records, but I hope this chapter will help develop your intuition of the values they represent and show you how they behave beyond trivial cases.
type SomeObject = { key1: boolean; key2: number };
type SomeRecord = { [key: string]: number };
Before we start, let me remind you about some important notions we covered in the previous chapter because they will also be relevant to this one. We briefly went through the five main categories of types — primitives, literals, data structures, unions and intersections — before diving into the relationship between types and Set Theory.
We've discovered that types represent sets of values. Just like sets, types can include other types and we call this relationship subtyping.
Finally, we got acquainted with two peculiar types: never
— the empty set, and unknown
— the set that contains everything else.
Now, let's focus on two of the four data-structure types we mentioned in the previous chapter: Objects and Records:
type FourKindsOfDataStructures =
| { key1: boolean; key2: number } // objects
| { [key: string]: number } // records
| [boolean, number] // tuples
| number[]; // arrays
Object types
Object types define sets of JavaScript objects. The syntax to create an object type is very similar to the way we create regular objects:
type User = {
name: string;
age: number;
isAdmin: boolean;
};
In fact, they are the type-level equivalent of JS objects. Just like them, they can contain as many properties as we'd like, and each property is indexed by a unique key. Notice that each key can contain a different type: the name
key holds a value of type string
but the age
key holds a value of type number
here.
The User
type we've created is the set of all objects with a name
, an age
and a isAdmin
property containing values of the correct types:
// ✅ this object is in the `User` set.
const gabriel: User = {
name: "Gabriel",
isAdmin: true,
age: 28,
};
// ❌
const bob: User = {
name: "Bob",
age: 45,
// <- the `isAdmin` key is missing.
};
// ❌
const peter: User = {
name: "Peter",
isAdmin: false,
age: "45" /* <- the `age` key should be of type `number`,
but it's assigned to a `string`. */,
};
// ✅ this object is in the `User` set.
const gabriel: User = {
name: "Gabriel",
isAdmin: true,
age: 28,
};
// ❌
const bob: User = {
name: "Bob",
age: 45,
// <- the `isAdmin` key is missing.
};
// ❌
const peter: User = {
name: "Peter",
isAdmin: false,
age: "45" /* <- the `age` key should be of type `number`,
but it's assigned to a `string`. */,
};
But what about objects with extra properties? Are they also assignable to our User
type?
Well, yes... and no. Let me explain!
Assignability of objects
If you try assigning an object to a variable with an explicit type annotation like what we've been doing so far, TypeScript will reject extra properties:
const alice: User = {
name: "Alice",
age: 35,
isAdmin: true,
bio: "...", // ❌ This doesn't type-check.
};
const alice: User = {
name: "Alice",
age: 35,
isAdmin: true,
bio: "...", // ❌ This doesn't type-check.
};
Here is the full error you will get:
The term Object literal refers to objects defined inline, using the {}
syntax.
What if we were assigning a pre-existing object with extra properties to alice
instead?
const looksLikeAUser = {
name: "Alice",
age: 35,
isAdmin: true,
bio: "...", // <- extra prop!
};
// ✅ This works just fine!
const user: User = looksLikeAUser;
const looksLikeAUser = {
name: "Alice",
age: 35,
isAdmin: true,
bio: "...", // <- extra prop!
};
// ✅ This works just fine!
const user: User = looksLikeAUser;
TypeScript would be ok with that.
The definitive answer is that objects with more properties are assignable to objects with fewer properties, but in contexts where objects are defined inline, TypeScript has extra rules to make sure we don't mistakenly assign props that we couldn't use afterward because our types would forbid us to do so.
This means that you have absolutely no guarantee that an object of some type does not contain extra props!1 An object type is the set of objects with at least all properties it defines.
💡 By the way, what's the difference between object
and {}
in TypeScript?
Now, what can we do with our type-level objects?
Reading properties
To access the type of a property, we can use the square brackets notation:
type User = { name: string; age: number; isAdmin: boolean };
type Age = User["age"]; // => number
type Role = User["isAdmin"]; // => boolean
But the dot notation does not work!
type Age = User.age;
// ^ ❌ syntax error!
type Age = User.age;
// ^ ❌ syntax error!
That's okay. The square brackets notation and the dot notation are equivalent anyway at the value level.
Reading several properties at once
You may have noticed that in expressions such as User["age"]
, the key ("age"
) is a literal type. What do you think will happen if you try to pass a union of literals instead?
type User = { name: string; age: number; isAdmin: boolean };
type NameOrAge = User["name" | "age"]; // => string | number
Well, it just works! It's as if we were accessing "name"
and "age"
simultaneously, and getting back a union of the types they contained. Accessing several keys from an object is equivalent to accessing each key separately and constructing a union type with their results afterward:
type NameOrAge = User["name"] | User["age"]; // => string | number
It's just shorter!2
The keyof
keyword
The keyof
keyword lets you retrieve the union of all keys in an object type. You can place it right before any object:
type User = {
name: string;
age: number;
isAdmin: boolean;
};
type Keys = keyof User; // "name" | "age" | "isAdmin"
Since keyof
returns a union of string literals, we can combine it with the square brackets notation to retrieve the union of the types of all values in this object!
type User = {
name: string;
age: number;
isAdmin: boolean;
};
type UserValues = User[keyof User]; // string | number | boolean
This is such a common use-case that people often define a ValueOf
generic type to abstract over this pattern:
type ValueOf<Obj> = Obj[keyof Obj];
type UserValues = ValueOf<User>; // string | number | boolean
Generics are type-level functions. Now, we can reuse this logic with any object type! 🎉
Optional properties
Object types can define properties that may or may not be present. The way to set a property as optional is to use the ?:
key modifier:
type BlogPost = { title: string; tags?: string[] };
// ^ this property is optional!
// ✅ No `tags` property
const blogBost1: BlogPost = { title: "introduction" };
// ✅ `tags` contains a list of strings
const blogBost2: BlogPost = {
title: "part 1",
tags: ["#easy", "#beginner-friendly"],
};
But why do we need some special syntax where it looks like a union type like T | undefined
would do the trick?
type BlogPost = { title: string; tags: string[] | undefined };
This is because objects must define values for all keys present on their type. TypeScript would have asked us to explicitly assign a tags
key to undefined
:
const blogBost3: BlogPost = { title: "part 1" };
// ^ ❌ type error: the `tags` key is missing.
// ✅
const blogBost4: BlogPost = { title: "part 1", tags: undefined };
const blogBost3: BlogPost = { title: "part 1" };
// ^ ❌ type error: the `tags` key is missing.
// ✅
const blogBost4: BlogPost = { title: "part 1", tags: undefined };
Having to assign all optional properties to undefined
wouldn't be convenient. It's much better to have a way of telling TypeScript that a property can be omitted!
Merging object types with intersections (&
)
To make our code more modular, it's sometimes useful to split type definitions into multiple object types. Let's split our User
type into three parts:
type WithName = { name: string };
type WithAge = { age: number };
type WithRole = { isAdmin: boolean };
Now we need a way to re-assemble them into a single type. We can use an intersection for that:
type User = WithName & WithAge & WithRole;
type Organization = WithName & WithAge; // organizations don't have a isAdmin
We've seen in the previous chapter that intersections create types with all properties of intersected types, so this new definition of User
is equivalent to the previous one.
Intersections of objects and unions of keys
Wait. If the type {a: string, b: number}
is the intersection of {a: string}
and {b: number}
, why does this type contain the union of their keys: 'a' | 'b'
? 🤔
Here is why: we are not intersecting their keys, we are intersecting the sets of values they represent.
Since object types with additional keys are assignable to object types with fewer keys, there are objects containing a key b
of type number
in the set {a: string}
. There are also objects containing a key a
of type string
in the set {b: number}
.
Intersecting {a: string}
with {b: number}
returns the set of values that belong to both sets, which is expressed as {a: string, b: number}
.
It turns out that the intersection of two objects contains the union of their keys:
type A = { a: string };
type KeyOfA = keyof A; // => 'a'
type B = { b: number };
type KeyOfB = keyof B; // => 'b'
type C = A & B;
type KeyOfC = keyof C; // => 'a' | 'b'
Conversely, the union of two objects contains the intersection of their keys:
type A = { a: string; c: boolean };
type KeyOfA = keyof A; // => 'a' | 'c'
type B = { b: number; c: boolean };
type KeyOfB = keyof B; // => 'b' | 'c'
type C = A | B;
type KeyOfC = keyof C; // => ('a' | 'c') & ('b' | 'c') <=> 'c'
Another way to think about this is that if you have a value of either type A
or type B
, the only key that will be present in both cases is "c"
.
Here is the general rule:
keyof (A & B) = (keyof A) | (keyof B)
keyof (A | B) = (keyof A) & (keyof B)
This is a bit subtle, I hope this makes sense!
Caveats of intersections of objects
Using intersections to merge objects has two caveats.
First, intersections are applied recursively on all object properties, so if some property is present on both types it will be intersected too!
This can yield unexpected results. Especially if the shared property contains types that do not overlap with each other. In this case it will result in the never
type (also known as the empty set):
type WithName = { name: string; id: string };
type WithAge = { age: number; id: number };
type User = WithName & WithAge;
type Id = User["id"]; // => string & number <=> never
If you know for a fact that your objects do not have overlapping properties, then merging them using an intersection is totally fine.
Secondly, intersections of objects can sometimes be detrimental to type-checking performance3. If your type definitions are static (meaning that they don't depend on type parameters), you can achieve the same result using interfaces
and the extends
keyword:
interface User extends WithName, WithAge, WithRole {}
interface Organization extends WithName, WithAge {}
Interfaces have better performance, but they can only be defined statically. We won't be able to create them in type-level functions for instance.
Records
Just like Object types, Records also represent sets of objects. The difference is that all keys of a record must share the same type.
A record of booleans is defined like this:
type RecordOfBooleans = { [key: string]: boolean };
You can read this as "any key assignable to string
has a value of type boolean
."
Records can also be defined using the built-in Record
generic:
type RecordOfBooleans = Record<string, boolean>;
which is defined as follows:
type Record<K, V> = { [Key in K]: V };
Notice the in
keyword. This is using a feature called Mapped Types, which we will cover in more detail in a dedicated chapter. Briefly, in
lets us assign a type of value for every key in the union K
.
In our previous example, we passed the type string
as K
, but we could also have used a union of string literals:
type InputState = Record<"valid" | "edited" | "focused", boolean>;
Or without using Record
:
type InputState = { [Key in "valid" | "edited" | "focused"]: boolean };
Which is equivalent to:
type InputState = { valid: boolean; edited: boolean; focused: boolean };
For our type-level programs, Records aren't the most interesting data structures because they contain a single type whereas objects can contain many different types. Most of the time, we will only need to extract the type of their values. We can use [string]
for that:
type ValueType = RecordOfBooleans[string]; // => boolean
We simultaneously read all keys assignable to the type string
. Since all of them contain booleans, we get the boolean
type back.
Helper functions
TypeScript provides several built-in helper functions to deal with object types which are extremely useful. We will soon learn how to create our own functions that transform objects in various ways using Mapped Types, but for now, let's see what's available natively.
Partial
The Partial
generic takes an object type and returns another one that's identical except that all of its properties are optional:
type Props = { value: string; focused: boolean; edited: boolean };
type PartialProps = Partial<Props>;
// is equivalent to:
type PartialProps = { value?: string; focused?: boolean; edited?: boolean };
Required
The Required
generic does the opposite of Partial
. It takes an object and returns another one that's identical except that all of its properties are required:
type Props = { value?: string; focused?: boolean; edited?: boolean };
type RequiredProps = Required<Props>;
// is equivalent to:
type RequiredProps = { value: string; focused: boolean; edited: boolean };
Pick
The Pick
generic removes all keys that aren't assignable to the type of key given as second argument:
type Props = { value: string; focused: boolean; edited: boolean };
type ValueProps = Pick<Props, "value">;
// is equivalent to:
type ValueProps = { value: string };
type SomeProps = Pick<Props, "value" | "focused">;
// is equivalent to:
type SomeProps = { value: string; focused: boolean };
Omit
The Omit
generic removes all object properties that are assignable to the type given as second argument. It does the opposite of Pick
!
type Props = { value: string; focused: boolean; edited: boolean };
type ValueProps = Omit<Props, "value">;
// is equivalent to:
type ValueProps = { edited: boolean; focused: boolean };
type OtherProps = Omit<Props, "value" | "focused">;
// is equivalent to:
type OtherProps = { edited: boolean };
Summary
Even though you probably already knew about objects and records, I hope this chapter developed your intuition of the kind of values they represent. Objects and Records are the foundation upon which we will build our understanding of more advanced concepts for more complex use cases. This foundation must be solid!
Here are some notions we covered:
- Object types and Records both represent sets of JavaScript objects.
- Object types are sets of objects containing at least all properties defined on this type, but they can also contain more properties.
- Record types are sets of objects that share the same type for all properties.
- Intersections let us "merge" objects together in types containing all of their properties.
- TypeScript provides several built-in functions like
Partial
,Required
,Pick
andOmit
to transform object types.
Challenges! 🎊
Time to practice! Check out the How Challenges Work section from Types & Values if you haven't already to learn how to take these type challenges.
Footnotes
-
This is why
Object.keys(...)
returns astring[]
and not a(keyof Obj)[]
by the way.(keyof Obj)[]
would be incorrect becauseObject.keys(...)
could return strings that are not assignable tokeyof Obj
. ↩ -
The real reason why it's possible to read several properties at once using a union type is because the type level expression
User["name"]
and the value level expressionuser["name"]
work differently. Instead of finding the key equal to"name"
in the typeUser
, TypeScript tries to find every key inUser
that is assignable to the type"name"
!If we write
User["name" | "age"]
, TS will find all keys assignable to the type"name" | "age"
and return their value types. Pretty cool right? ↩ -
Using too many intersection types can be detrimental to type-checking performance. In the previous chapter (Types are just data), I explained that intersection types were data structures. Instead of actually merging object types into one single entity, they keep each individual object type in memory in a sort of internal list of intersected types. Because of this, the type-checker needs to allocate more memory and will take more time to check if object types are assignable because it will need to traverse this internal list to check each object type one by one.
In that regard, interfaces are better because they merge type definitions into one flat structure. Their downside is that they cannot be created dynamically, say from type parameters inside a generic. That's why using intersections to merge objects is still a useful trick to know! ↩
If you enjoyed this free chapter of Type-Level TypeScript, consider supporting it by becoming a member!
Enroll in Type-Level TypeScript!
Get access to all chapters of the Type-Level TypeScript course and join a community of 1600+ students!
You will find everything you need to become a TypeScript Expert — 11 chapters of in-depth, unique content, and more than 70 fun challenges to practice your new skills.
Full access to all 11 chapters
70 type challenges with explanations
Lifetime access to all course materials
Exclusive discord community
Loading...