Steve Ruiz

  1. Home
  2. About
  3. Archive

The Smooshed Object Union type in TypeScript

The Smooshed Object Union type in TypeScript

I ran into a little TypeScript situation earlier this week. I'm sure I'll run into it again, so I thought I'd share it here.

đŸ‘‹ Just want to look at some code? Click here for the TypeScript Playground.

The Setup

I'd been working with different shapes for tldraw. Each shape had different properties stored in a props object. Some properties were shared between shape types, such as color, or some unique to a particular shape type (e.g. sides for a polygon).

type Box = {
    type: 'box'
    props: {
        color: string
        height: number
        width: number
    }
}

type Polygon = {
    type: 'polygon'
    props: {
        color: string
        height: number
        width: number
        sides: number
    }
}

type PolyLine = {
    type: 'polyline'
    props: {
        color: string
        points: number[]
    }
}

Each of these properties (except for type) could be set via properties panel. The properties panel only showed the properties for the current tool (e.g. sides only when the Polygon tool was active), or else for the current selected shapes if no tool was active (e.g. both sides and points if a user had selected both a polyline and polygon).

I wanted to store the user's most recent choices from those panels in the application's state, so that their choices would be "sticky" to the panel. That meant creating an object that had all of the properties and all of the values from all of the shapes:

type Properties = {
    color: string
    height: number
    width: number
    sides: number
    points: number[]
}

To create that type, I first tried creating a union of the shapes:

type Shapes = Box | Polygon | PolyLine

const properties: Shapes['props'] = {
    color: 'black',
    height: 12,
    width: 12,
    sides: 4,
    points: [],
}

This worked!

The Problem

But then I needed a method to update this object when the user picked a property from the properties panel.

function setProp<T extends keyof Shapes['props']>(
    prop: T,
    value: Shapes['props'][T]
) {
    properties[prop] = value
}

And here's where I ran into trouble.

setProp('color', 'black')
setProp('height', 32)
// ^ Error!
//   Argument of type '"height"' is not assignable
//   to parameter of type '"color"'.ts(2345)

Because keyof Shapes["props"] is color.

When getting the keys of a union, only the "common" properties are present—meaning the properties that are found in each of the union's members. In our case, only color is found in all three.

So I was left with the problem: how do you get all of the keys in a union of objects, including the ones that aren't common to all members of the union?

The Solution

Here's what I came up with:

type SmooshedObjectUnion<T> = {
    [K in T extends infer P ? keyof P : never]: T extends infer P
        ? K extends keyof P
            ? P[K]
            : never
        : never
}

Rather than typing properties as Shapes['props'], I instead typed it as a smooshed object union of Shapes['props'].

type AllProperties = SmooshedObjectUnion<Shapes['props']>

const properties: AllProperties = {
    color: 'black',
    height: 12,
    width: 12,
    sides: 4,
    points: [],
}

Because this smooshed object union is keyable, I could then type my function correctly.

function setProp<T extends keyof AllProperties>(
    prop: T,
    value: AllProperties[T]
) {
    properties[prop] = value
}

setProp('color', 'black')
setProp('height', 32)

Problem solved!

Breakdown

The SmooshedObjectUnion, cursed though it looks, is really a combination of several smaller and slightly less ugly utility types.

The first is a helper that returns the value of a property that may or may not exist in an object.

type MaybeValue<T, K> = K extends keyof T ? T[K] : never

type X = MaybeValue<{ sides: number }, 'sides'> // number
type Y = MaybeValue<{ sides: number }, 'color'> // never

The second uses MaybeValue to collect all of the values in a union under a certain key.

type ValueInUnion<T, K> = T extends infer P ? MaybeValue<P, K> : never

And the third is a utility that gives all of the keys in a union, including those not common to all members:

type KeysOfUnion<T> = T extends infer P ? keyof P : never

type X = KeysOfUnion<Shapes['props']>
// 'color' | 'height' | 'width' | 'sides' | 'points'

And then SmooshedObjectUnion would combine these three:

type SmooshedObjectUnion<T> = {
    [K in KeysOfUnion<T>]: ValueInUnion<T, K>
}

Bonus Alternative Implementation

Here's a second implementation by Devansh Jethmalani.

type DistributedIdentity<T> = {
    [K in T extends unknown ? keyof T : never]: T extends { [_ in K]: infer V }
        ? V
        : never
}

He calls it DistributedIdentity—but I'll let you decide whether that's more or less accurate than SmooshedObjectUnion.


Here's that link again for the TypeScript Playground.

Enjoy!

Twitter
  • Increment a Name in TypeScript

    How to increment a name to avoid duplicates.

Steve Ruiz © 2022

hey click here