Set theory and 'keyof' spell

Set theory and 'keyof' spell

Thinking of TypeScript types as sets of properties lowered my learning curve of types system. Let me show you how, and what you can accomplish by following this rule. Dive into keyof and mapped types magic with the first article in the series.

You may consider this as a curiosity, as a coding exercise or actual helpful solution to your cases. Read ahead and find out.

Recap from math classes

Given you have two sets. What can you do with them?

Create a union to have items from both of them:

Key sets union

Create an intersection of them to get the common part:

Key sets intersection

Differ them on one or other side or both, create a cartesian product and so on. I think you know the drill now. Did you also notice the items in sets?

We could translate these two sets into two types:

type Customer = {
    email: string,
    login: string,
    firstName: string,
    lastName: string
}

type User = {
    email: string,
    login: string,
    roles: Role[],
    addRole: (newRole: Role) => void
}

And we can do the same operations on these types as we did with sets.

Math of types

Try to apply set-related operations to our two types. This will allow us to learn some tricks with how keys and type manipulation works in TypeScript.

Union

The union is pretty straightforward we simply merge types with pipe & operator.

// union of two types merges their properties
type CustomerUser = Customer & User
// result type contains: email, login, firstName, lastName, roles, addRole

The weird thing is that for literal types we need to use pipe | operator:

// union of two literal types
type CustomerUserKey = keyof Customer | keyof User 
// 'email', 'login', 'firstName', 'lastName', 'roles', 'addRole'
Union usage: There are multiple possible usages of this pattern mainly for composition. For example, you can join two types of props for components that consist of two different ones, that require different properties.

But when you get into other operations it can get more tricky. I can show you a little hint, that I have not found anywhere else.

Intersection

To get the common part (intersection) of two types, we need to determine which of their keys repeat. We do this by joining sets of keys with & operator.

// sets of keys can be intersected by '&' operator
// this results in only shared keys to be assigned to new type
type CustomerUserSharedKey = keyof Customer & keyof User // email, login

Having this set will allow us to prepare types corresponding to the results of other set operations.

For deeper manipulation with type properties, we must iterate over their keys with the use of a mapping index. You can think about it as a for loop for keys in an object, but operating on an abstract types level. Its possibilities are limited, but you still can achieve a lot with it.

// From the set of Customer and User keys we select only ones shared by both
type CustomerUserIdentifier = {
    [key in keyof Customer & keyof User]:  (Customer & User)[key] 
    // [key in keyof CustomerUserSharedKey]: CustomerUser[key]
}
// result has email and login only
Intersection usage: Having shared keys of multiple types you can combine them to create a unique key or hash for an element. Or put multiple types to the same data structure by assigning it to an 'intersected' type.

Difference

To apply this operation to types, we need to delve deeper into typescript magical types world and discover never type and conditional types.

For our usage case, you can think of never as a kind of null or no value in types world. Whenever something becomes never compiler removes it from types declarations. Combining it with some conditions, that we assign for our keys we can exclude the shared properties from type.

// remove shared properties form 'Customer' type
// iterate over all Customer keys and remove the ones shared by both types
// by applying condition to them and returnin 'never' if it is met
type CustomerNotUser = {
// (parenthesis can be removed, added them for brevity only)
    [key in keyof Customer 
        as (key extends (keyof Customer & keyof User) ? never : key)]:
               Customer[key] 
}

// Result type will have 'firstName' and 'lastName' only

After the as keyword in the key selector, we use a ternary conditional operator to decide if to return the key or never. To specify the condition on key we use extends syntax. Only keys other than shared ones are taken in the result from the original Customer type.

Difference usage: You can use this method to exclude from result properties already provided in the other type. For example, in my recent article about dependency injection in React, I presented a solution that removed props provided by the dependency container from the resulting component props type.

Symmetrical difference

In this case, we want to have a type that has all properties of both types except the ones shared by both of them.

For this, we do the same thing as for difference only that we iterate over the union of two types and its keys rather than one.

type CustomerUserNoSharedProps = {
// we iterate over keys in both types instead of one
    [key in keyof (Customer & User) 
        as (key extends (keyof Customer & keyof User) ? never : key)]:
               (Customer & User)[key] 
}
// result has: firstName, lastName, roles, addRole

A shorter version using predefined types would look like this:

type CustomerUserNoSharedPropsShort = {
    [key in keyof CustomerUser
        as key extends keyof CustomerUserSharedKey ? never : key]:
               CustomerUser[key] 
}

I can not think about the practical usage of this pattern, but still, it is a good exercise that teaches several concepts related to mapped types, their constraints and conditional manipulation.

Set operations utility types

For now, we worked on the sample types to show the concept. But we can generalize the concept and create utility types for all these operations.

// Set operations applied to types (sets of properties)
type Union<T1, T2> = T1 & T2 // yeah this ishere  just for completing the set

type Intersect<T1, T2> = {
    [key in keyof T1 & keyof T2]: Union<T1, T2>[key]
}

type Diff<T1, T2> = {
    [key in keyof T1 as (key extends keyof Intersect<T1, T2> ? never : key)]:
        T1[key]
}

type DiffSymetrical<T1, T2> = {
    [key in keyof Union<T1, T2> as 
        (key extends keyof Intersect<T1, T2> ? never : key)]:
            Union<T1, T2>[key]
}

You could use existing utility types to do that like Exclude or Pick, but they are working on specific keys, and here we have explicit operations done on types directly.

Lessons learned

Comparing types to sets of properties and playing with them a bit, was a very thoughtful exercise. We touched on several concepts here:

  • keyof

  • union types

  • union of keys

  • mapped types and iterating over them

  • never type

  • conditional type constraints

And even though we used them sparingly they present practical solutions to specific cases.

This is only a gateway to the magical world of TypeScript. In incoming articles, I want to discover more interesting parts of it and reveal how it can help us in peculiar cases.

Grab the code from my GitHub repo. Keep an eye on new articles and in the meantime have fun learning :)