Type Catalogs ๐ a better way to work with union types
โ by Gabriel Vergnaud ยท Feb 22, 2025
Ever since I started using TypeScript, I've been a heavy user of discriminated unions of objects. Up until recently, I've been hand-writing my union types the regular way:
type FeedItem = Podcast | PodcastEpisode | Album | Song;
But now, I tend to use the following pattern instead:
type FeedCatalog = {
podcast: Podcast;
episode: Episode;
album: Album;
song: Song;
};
export type FeedItem = FeedCatalog[keyof FeedCatalog];
I call these Type Catalogs. Here is why I like them better ๐
The problem with hand-written unions
Discriminated unions are one of TypeScript's most useful features because they enable us to represent heterogeneous collections, something we often need in our applications. If you are building:
- A search engine showing a list of links, images, videos, news, etc.
- A social media with a feed of posts, comments, likes, etc.
- A dashboard showing a grid of widgets, line charts, tables, maps, etc.
You will likely have a union type representing all the kinds of content your application can handle.
Let's say we're building a music streaming app with a recommendation feed. We might have a union that looks like this:
type FeedItem = Podcast | PodcastEpisode | Album | Song;
type Podcast = {
type: "podcast";
id: string;
title: string;
};
type PodcastEpisode = {
type: "episode";
src: string;
podcastId: string;
};
// ... and so on
This works great! But as your union grows, a few pain points start to emerge:
1. Type import explosion ๐ฅ
When you need to branch on your union types, you often need each individual type as well:
import { FeedItem, Podcast, PodcastEpisode, Album, Song } from "./types";
2. Creating sub-unions is verbose
Logic that runs on a subset of our unions is pretty common. With traditional union types, you have to create these sub-unions by hand:
type PlayableFeedItem = Episode | Song;
Or use a more advance method, like the built-in Extract
type helper:
type PlayableFeedItem = Extract<FeedItem, { type: "episode" | "song" }>;
Both of these options work, but the first one requires importing lots of individual types and the second one might look unfamiliar for engineers who don't have a perfect understanding of assignability of objects, or the distributive nature of union types.
3. Generic functions get messy
The type signatures of your generic functions also require using Extract
:
function getDefaultFeedItem<T extends FeedItem["type"]>(
type: T,
): Extract<FeedItem, { type: T }> {
// ...
}
To type getDefaultFeedItem
, we have to filter the FeedItem
union using Extract
to only include the member that has the right type
property. It works, but it's more complex than it needs to be.
Enter Type Catalogs ๐
A type catalog is just a map of your union members:
export type FeedCatalog = {
podcast: Podcast;
episode: Episode;
album: Album;
song: Song;
};
type FeedCatalog = {
podcast: Podcast;
episode: Episode;
album: Album;
song: Song;
};
type Podcast = {
type: "podcast";
id: string;
title: string;
};
type Episode = {
type: "episode";
src: string;
podcastId: string;
};
type Album = {
type: "album";
id: string;
title: string;
};
type Song = {
type: "song";
id: string;
title: string;
src: string;
albumId: string;
};
// All keys:
type FeedItemType = keyof FeedCatalog;
// The full union (equivalent to our original FeedItem):
type FeedItem = FeedCatalog[FeedItemType];
From this structure, we can retrieve the exact same union we had before:
// All keys:
export type FeedItemType = keyof FeedCatalog;
// The full union (equivalent to our original FeedItem):
export type FeedItem = FeedCatalog[FeedItemType];
But if they are equivalent, how do Type Catalogs solve our previous pain points? Let's find out!
1. Cleaner Imports โจ
The only type you need to import now is the catalog:
import { FeedCatalog } from "~/types";
To use it, you can look up specific keys on the catalog:
// โ
Type-checks!
const latestPodcast: FeedCatalog["podcast"] = {
type: "podcast",
id: "tech-talk-123",
title: "TypeScript Tips & Tricks",
};
// โ Property 'podcastId' is missing
const brokenEpisode: FeedCatalog["episode"] = {
type: "episode",
src: "https://...",
};
2. Easy sub-unions ๐จ
Creating a sub-union becomes more straightforward:
type PlayableFeedItem = FeedCatalog["episode" | "podcast"];
// => { type: "episode"; ... } | { type: "podcast"; ... }
We can simply use the regular object access syntax with a union of keys instead of using Extract
!
3. Simpler Generic Functions ๐ฏ
Generic functions become much more readable:
function createDefaultFeedItem<T extends FeedItemType>(
item: T,
): FeedCatalog[T] {
// ...
}
Pretty easy, right?
4. Type catalog transformations ๐
Type catalogs really shine when you need to transform each member of your union. Let's look at a use case I faced many times: converting frontend object types into the backend format used by our HTTP API!
When working with backend APIs, we often need to:
- Convert property names to
snake_case
. - Wrap data in a JSON-API-like format.
- Add some metadata.
Let's assume we have the following snake_case
converter utilities in our codebase already:
// Converts camelCase string to snake_case:
type ToSnakeCase<T extends string> =
T extends `${infer First}${infer Rest}`
? First extends Uppercase<First>
? `_${Lowercase<First>}${ToSnakeCase<Rest>}`
: `${First}${ToSnakeCase<Rest>}`
: T;
// Converts all keys in an object
// from camel to snake recursively:
type ObjectToSnakeCase<T> = {
[K in keyof T as ToSnakeCase<K & string>]: ObjectToSnakeCase<T[K]>;
};
type ToSnakeCase<T extends string> = T extends `${infer First}${infer Rest}`
? First extends Uppercase<First>
? `_${Lowercase<First>}${ToSnakeCase<Rest>}`
: `${First}${ToSnakeCase<Rest>}`
: T;
type ObjectToSnakeCase<T> =
| { [K in keyof T as ToSnakeCase<K & string>]: ObjectToSnakeCase<T[K]> }
| never;
๐ก If you want to better understand ToSnakeCase
and ObjectToSnakeCase
, you can read the Template Literal Types chapter of Type-Level TypeScript!
Using a Mapped Type, we can transform each feed item to its API format counterpart and create a new APIFeedCatalog
:
type ApiFeedCatalog = {
// ๐ loop through each FeedItem
[K in keyof FeedCatalog]: {
data: ObjectToSnakeCase<FeedCatalog[K]>;
metadata: {
client_id: string;
};
};
};
Let's see what our transformed types look like:
type PodcastApiResponse = ApiFeedCatalog["episode"];
/*
{
data: {
type: "episode";
src: string;
podcast_id: string; // ๐ Automatically converted!
};
metadata: {
client_id: string;
};
}
*/
type PodcastApiResponse = ApiFeedCatalog["episode"];
/*
{
data: {
type: "episode";
src: string;
podcast_id: string; // ๐ Automatically converted!
};
metadata: {
client_id: string;
};
}
*/
Transforming our catalog with a Mapped Type instead of creating ApiFeedCatalog
by hand is a great idea because we won't have to worry about introducing inconsistencies when adding new feed items in the future.
In addition, we can write type-safe generic functions to convert from one format to the other very easily:
function convertToApi<T extends FeedItem>(
item: T
): ApiFeedCatalog[T["type"]] {
// ...
}
// hover to see the inferred type:
// ๐
const apiEpisode = convertToApi({
type: "episode", // ๐ try updating this to a "podcast"
src: "https://...",
podcastId: "123",
});
// hover to see the inferred type:
// ๐
const apiEpisode = convertToApi({
type: "episode", // ๐ try updating this to a "podcast"
src: "https://...",
podcastId: "123",
});
Deriving Catalogs from Unions
There is a single downside to starting from a type catalog: you could make a typo and have a catalog key that doesn't match the corresponding object's type property:
type FeedCatalog = {
oops: { type: "album", ... }
// ^ ๐ฌ
}
The good news is that type catalogs can be automatically derived from union type, also using a Mapped Type:
type Podcast = {
type: "podcast";
id: string;
title: string;
};
type Episode = {
type: "episode";
src: string;
podcastId: string;
};
type Album = {
type: "album";
id: string;
title: string;
};
type Song = {
type: "song";
id: string;
title: string;
src: string;
albumId: string;
};
export type FeedItem = Podcast | Episode | Album | Song;
// hover to see the inferred type
// ๐
export type FeedCatalog = {
[Item in FeedItem as Item["type"]]: Item;
};
export type FeedItem = Podcast | Episode | Album | Song;
// hover to see the inferred type
// ๐
export type FeedCatalog = {
[Item in FeedItem as Item["type"]]: Item;
};
๐ก If you aren't familiar with the as
keyword in this context, it is called key remapping. Read Loops with Mapped Types to learn more!
As you can see when hovering over the FeedCatalog
type, this code creates the exact same catalog.
This is the best of both worlds: we get all the benefits of type catalogs without letting the opportunity for a typo to sneak in!
Summary ๐
Type catalogs offer a more ergonomic way to work with discriminated unions in TypeScript. They provide:
- Cleaner type imports.
- Easier creation of sub-unions.
- Simpler generic function signatures.
- They can be easily transformed through Mapped Types.
If, like me, you are a heavy user of large unions of objects, I'm sure you'll find this pattern very useful!
If you liked this article, chances are you'll like Type-Level Typescript. It's an advanced TypeScript course that will give you a solid understanding of the type system's fundamentals and guide you through its most advanced features. Enroll Now!
Subscribe to the newsletter!
Receive all new chapters and articles from Type-Level TypeScript directly in your inbox!