DEV Community

Cover image for Breaking Down a Complex Mapped Type
Josh Branchaud
Josh Branchaud

Posted on

Breaking Down a Complex Mapped Type

There is a useful generated type pattern in TypeScript called a mapped type. Let's make sense of it by starting from first principles and building up to the full example


While watching Matt Pocock's video on Using DECLARE GLOBAL for amazing type inference, I was pretty stuck trying to make sense of the type of event in the GlobalReducer.

Image description

There are a lot of pieces to this type and, at least for me, none of them are particularly intuitive. For me to make sense of it, I needed to break it down into smaller pieces first.

Here is a walkthrough of that process.

For starters, here is a GlobalReducerEvent that we can add and remove reducer types from.

interface GlobalReducerEvent {
    ADD_TODO: {
        text: string
    }
    LOG_IN: {
        email: string
    }
    DELETE_TODO: {
        todo_id: number
    }
}

type ReducerEvent = ... /* what we're about to build */

type GlobalReducer<TState> = (
    state: TState,
    event: ReducerEvent
) => TState
Enter fullscreen mode Exit fullscreen mode

We want our GlobalReducer to do type checking based on what events are included. For that we'll need to use some fun TypeScript features that allow us to map the GlobalReducerEvent into a type that is useful in our user-land code.

// this is our target type
type ReducerEvent = ({
    type: "ADD_TODO";
    text: string;
}) | ({
    type: "LOG_IN";
    email: string;
}) | ({
    type: "DELETE_TODO";
    todo_id: number;
})
Enter fullscreen mode Exit fullscreen mode

Building up to the ReducerEvent (the type of event) is our end goal here.

Starting with keyof, we can get a union type of all the events.

// What are our different types of events?
type EventUnion = keyof GlobalReducerEvent;
/*
type EventUnion = 'ADD_TODO' | 'LOG_IN' | 'DELETE_TODO'
*/
Enter fullscreen mode Exit fullscreen mode

That is a crucial piece of the puzzle as it is used in two different places in the end result.

The next few steps are going to be building up an intermediate type of sorts. A type that will help us map the original type into our target type.

With in, we can create an index signature over the values from EventUnion (which remember is keyof GlobalReducerEvent). For the moment, we'll type each of those keys as any.

// Gather up the types of events
// (we'll replace the `any` in a moment)
type EventTypes = {
    [EventType in EventUnion]: any
};
/*
type EventTypes = {
    ADD_TODO: any;
    LOG_IN: any;
    DELETE_TODO: any;
}

same as doing:

type EventTypes = {
    [EventType in keyof GlobalReducerEvent]: any
}
*/
Enter fullscreen mode Exit fullscreen mode

Now instead of typing them as any, let's key them to an object. This object is going to start building toward the values we are trying to map to.

Each event should have a type whose value matches its name. EventType is a reference to the name of the event, so we can use that in the object type ({ type: EventType }).

// Event type keyed to itself as an object
// (weird intermediate type, stick with me here)
type EventTypesWithSelf = {
    [EventType in keyof GlobalReducerEvent]: {
        type: EventType
    }
};
/*
type EventTypesWithSelf = {
    ADD_TODO: {
        type: "ADD_TODO";
    };
    LOG_IN: {
        type: "LOG_IN";
    };
    DELETE_TODO: {
        type: "DELETE_TODO";
    };
}
*/
Enter fullscreen mode Exit fullscreen mode

Each event has typings defining the data that needs to go with it. For instance, ADD_TODO requires a bit of text that constitutes the thing to be done. Hence, ADD_TODO: { text: string }. In this next part, we are going to fold in the typings for each event's data.

We can use the intersection type literal (&) to combine each event type ({type: 'ADD_TODO'}) with its data typing ({text: string}).

// Event type keyed to itself and its data
// (still looks weird, but starting to take shape)
type EventTypesWithSelfAndData = {
    [EventType in keyof GlobalReducerEvent]: {
        type: EventType
    } & GlobalReducerEvent[EventType]
};
/*
type EventTypesWithSelfAndData = {
    ADD_TODO: {
        type: "ADD_TODO";
    } & {
        text: string;
    };
    LOG_IN: {
        type: "LOG_IN";
    } & {
        email: string;
    };
    DELETE_TODO: {
        type: "DELETE_TODO";
    } & {
        todo_id: number;
    };
}

which you can think of as:

type EventTypesWithSelfAndData = {
    ADD_TODO: {
        type: "ADD_TODO";
        text: string;
    };
    LOG_IN: {
        type: "LOG_IN";
        email: string;
    };
    DELETE_TODO: {
        type: "DELETE_TODO";
        todo_id: number;
    };
}
*/
Enter fullscreen mode Exit fullscreen mode

Before we get to the last part, let's wrap our heads around indexed access of a type object.

The works a lot like accessing the value for a key from a JavaScript object. Using the type from the previous step, we are able to grab just the 'ADD_TODO' type.

// Let's try an indexed access of EventTypesWithSelfAndData
type AddTodoType = EventTypesWithSelfAndData['ADD_TODO']
/*
type AddTodoType = {
    type: "ADD_TODO";
} & {
    text: string;
}
*/
Enter fullscreen mode Exit fullscreen mode

Where it differs from how JavaScript object access works is evidenced by passing a union type instead of an individual value.

// What if we try to access multiple types at once with a union
type SomeEventTypes = EventTypesWithSelfAndData['ADD_TODO' | 'DELETE_TODO']
/*
type SomeEventTypes = ({
    type: "ADD_TODO";
} & {
    text: string;
}) | ({
    type: "DELETE_TODO";
} & {
    todo_id: number;
})
*/
Enter fullscreen mode Exit fullscreen mode

Notice the resulting type is a union type made up of just the types indexed at the union values.

We can take this a step further by doing an indexed access with keyof GlobalReducerEvent which is a union of all our event types.

// That means we can pass in a union of all our event type names
// to get a union of all the type signatures.
type AllEventTypes = EventTypesWithSelfAndData[keyof GlobalReducerEvent]
/*
type AllEventTypes = ({
    type: "ADD_TODO";
} & {
    text: string;
}) | ({
    type: "LOG_IN";
} & {
    email: string;
}) | ({
    type: "DELETE_TODO";
} & {
    todo_id: number;
})

which, you might remember, is equivalent to this:

type AllEventTypes = ({
    type: "ADD_TODO";
    text: string;
}) | ({
    type: "LOG_IN";
    email: string;
}) | ({
    type: "DELETE_TODO";
    todo_id: number;
})
*/
Enter fullscreen mode Exit fullscreen mode

That last example is equivalent to this full ReducerEvent. We built it up from all the constituent parts. Fun!

Let's take another look at the whole thing again.

type ReducerEvent = {
    [EventType in keyof GlobalReducerEvent]: {
        type: EventType
    } & GlobalReducerEvent[EventType]
}[keyof GlobalReducerEvent]
Enter fullscreen mode Exit fullscreen mode

Hopefully having walked through all the different pieces of this, it is looking less like magic and more like something you can reason about.

If you enjoyed this post, consider joining my newsletter or following me on twitter. If this helped you or you have a question, feel free to drop me note wherever. I'd love to hear from you!

Want to play around with this in more detail? Check it out in the TypeScript Playground.


Cover photo by GeoJango Maps on Unsplash

Top comments (0)