DEV Community

Cover image for Revealing Compound Types in Typescript
Kirk Shillingford
Kirk Shillingford

Posted on

Revealing Compound Types in Typescript

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 => {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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.

Intellisense for our Github User data type that appears when we hover over the type

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) => {
  // ... 
}
Enter fullscreen mode Exit fullscreen mode

Image highlighting the intellisense for our UnifiedUser type and how it just shows User and Github user but not their details

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>
Enter fullscreen mode Exit fullscreen mode

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.

Image showing how our Spread generic type reveals all the fields of User and GithubUser combined

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 use T 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 say SomeObject['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>
Enter fullscreen mode Exit fullscreen mode

Pressables suffers from the same problem as our object intersection.

A type pressable that is the union of two unions, hiding their imprementations

But SpreadPressables does not!

SpreadPressable type intellisense exposing all the values of the two unions that made it

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)

Collapse
 
raibtoffoletto profile image
Raí B. Toffoletto

🤯 Nice tip!! I'll definitely start using Spread instead of ^+Clicking the type to remember me what they are. Thanks for that!!

Collapse
 
matthewdean profile image
Matthew Dean

Holy shit, dude, this is so useful.