In this post I would like to show a small generic type I use sometimes when writing Typescript to improve intellisense and my developer experience.
The Problem
Frequently when writing Typescript, I run into the following scenario:
I have a set of types that I sometimes use separately or in combination depending on the context. I make heavy use of intellisense in my IDE to get information about the shape of data structures as I write.
// our representative for some application user object
type User = {
name: string
age: number
}
type Repository = {
name: string
url: string
}
// github data for our app users
type GithubUser = {
id: number
repos: Repository[]
}
// a certain set of functions only care about core user data
const fetchUser = (): User => {
// ...
}
// and others are concerned with finding and revealing github data
const fetchGithubUser = (): GithubUser => {
// ...
}
We have a User
and a GithubUser
type that both have different interfaces and their own methods for fetching and processing from their respective APIs. However, there are times in my UI when they need to be combined into a single entity
With both of these separately, intellisense can give us an idea of the shape of the data structure we can expect to see.
However, if we try to make a type that combines the two, using Typescript's (&) intersection syntax, we find that the intellisense now only shows use the type names, and will no longer reveal their shapes to us.
type UnifiedUser = User & GithubUser
// code where I need a unified object containing info from both
const displayUserdata = (data: UnifiedUser) => {
// ...
}
By combining multiple types, we've obscured their implementations.
The Solution
The solution here, is to use a small bit of Typescript's very advanced type manipulation abilities to re-expose the shape of our objects. We can do this using a combination of two concepts, mapped types, and Generics
Rather than go headlong into the full explanation of the solution, I'll present it first, and discuss how the pattern works after.
type Spread<Type> = { [Key in keyof Type]: Type[Key] }
type UnifiedUser = Spread<User & GithubUser>
Here, we've made a small generic type which I've called Spread
(there may be an better name for this), which we can pass our intersection of User
and GithubUser
, and we find that we can now see the object representing the combination of the two types.
How it works
Without getting too much into the weeds of the immensely deep, and admittedly, arcane syntax of the Typescript type system, what we've essentially done here is:
The type
Spread
is parametrized, meaning it itself accepts a type as part of its implementation, similar to how a function accepts a variable to use to figure out its result. It's a type who's value depends on another type!We've called that type variable that goes into
Spread
,Type
. Sometimes people useT
or some other generic name. Like regular variables, you can call type variables anything that isn't an existing type or keyword.After the equal sign, in the type body we define an object whose fields are the keys of the incoming type variable, and who's values are the values associated with those fields.
The
[Key in keyof Type]
syntax makes another type variable,Key
that represents all the keys (fields) of the type passed in.And finally,
T[Key]
, passes our key variable into our type variable, to get the value associated with that key! Just like how you would saySomeObject['somefield']
in a real JS object.This essentially maps the keys and values of the entire intersection into a object containing both.
The intellisense sees this new object now, and not the original types used to make the intersection.
Unions
One nice thing about this pattern is that it works for unions as well.
type Arrows = "Up" | "Down" | "Left" | "Right"
type Buttons = "X" | "Y" | "A" | "B"
type Pressables = Arrows | Buttons
type SpreadPressables = Spread<Arrows | Buttons>
Pressables
suffers from the same problem as our object intersection.
But SpreadPressables
does not!
Caveats
While this patterns works really well with these two use cases, it can become a little wonky otherwise.
One notable caveat is this solution doesn't work recursively. If a field in the intersection is itself some object, this won't expose that.
Mixing unions and intersections can also sometimes fail to spread out in a way that feels intuitive.
In Conclusion
If the above statements don't immediately click, that's fine! Understanding and Implementing these helper types isn't trivial, which is why Typescript provides a whole suite of Utility types which have useful built-ins for common transformations that people tend to use when working with TS/JS applications.
Also, there are many occasions where we want the implementation details of types to be squashed, especially when designing APIs. Once Typescript does expose type info, it is extremely difficult to keep it hidden if it exists in the same file. There are more advanced Type-level data structures called Opaque types that do just that, but opaque types go far beyond intellisense; they prevent the consumer from even making an implementation of the type.
There may be techniques to expand the utility of this pattern to cover for more use cases, but that's for another time. This post was just to highlight a small scenario that the utility types didn't have an answer for, but the solution turned out to be quite close at hand with some type-level programming!
Here's a link to a typescript playground where you can see all the code written here in it's entirety.
Thanks for reading :)
Top comments (2)
🤯 Nice tip!! I'll definitely start using Spread instead of ^+Clicking the type to remember me what they are. Thanks for that!!
Holy shit, dude, this is so useful.