July 25, 2024
Pattern for Building Data with Typescript Generics
#Overview
One problem I've run into quite a bit is how to properly manipulate and type data within Typescript, especially when working with third party integrations. In a perfect world, the right abstraction would allow for you to own your own types and for each integration point to properly handle the translation/data mapping.
Recently, I was working with a project where I wanted to translate the defined domain type (order.ts
) to a third party integration type
(integration.ts
) for a generic Typescript function (updateOrderEvent
). Looking at the code, it looked something like this:
import { z } from "zod";
export enum OrderEvent {
Purchase = "Purchase",
}
export const purchaseEventData = z.object({
id: z.string(),
sku: z.string(),
price: z.number(),
});
export type PurchaseEventData = z.infer<typeof purchaseEventData>;
export interface OrderEventData {
[OrderEvent.Purchase]: PurchaseEventData;
}
import { OrderEvent, OrderEventData } from "./order.ts";
export interface ThirdPartyOrderData {
[OrderEvent.Purchase]: {
id: string;
sku: string;
total_price: string;
};
}
export function buildThirdPartyOrderData<T extends OrderEvent>(
orderEvent: T,
orderEventData: OrderEventData[T]
): ThirdPartyOrderData[T] {
const thirdPartyOrderData: ThirdPartyOrderData = {
[OrderEvent.Purchase]: {
id: orderEventData.id,
sku: orderEventData.sku,
total_price: orderEventData.price,
},
};
return thirdPartyOrderData[orderEvent];
}
#The problem
This code works fine, and does its job well. However, when trying to add a new OrderEvent
, it becomes a little trickier
as the function buildThirdPartyOrderData
does not narrow orderEventData
at all.
To show this, if you were to add a new event like this:
import { z } from "zod";
export enum OrderEvent {
Purchase = "Purchase",
Shipped = "Shipped",
}
export const purchaseEventData = z.object({
id: z.string(),
sku: z.string(),
totalPrice: z.number(),
});
export type PurchaseEventData = z.infer<typeof purchaseEventData>;
export const shippedEventData = z.object({
shipmentDate: z.date(),
carrier: z.union([z.literal("UPS"), z.literal("FEDEX")]),
});
export type ShipmentEventData = z.infer<typeof shippedEventData>;
export interface OrderEventData {
[OrderEvent.Purchase]: PurchaseEventData;
[OrderEvent.Shipped]: ShipmentEventData;
}
import { OrderEvent, OrderEventData } from "./order.ts";
export interface ThirdPartyOrderData {
[OrderEvent.Purchase]: {
id: string;
sku: string;
total_price: string;
};
[OrderEvent.Shipped]: {
date_shipped: string;
carrier_brand: string;
};
}
export function buildThirdPartyOrderData<T extends OrderEvent>(
orderEvent: T,
orderEventData: OrderEventData[T]
): ThirdPartyOrderData[T] {
const thirdPartyOrderData: ThirdPartyOrderData = {
[OrderEvent.Purchase]: {
id: orderEventData.id,
sku: orderEventData.sku,
total_price: orderEventData.price,
},
[OrderEvent.Shipped]: {
date_shipped: orderEventData.shipmentDate,
carrier_brand: orderEventData.carrier,
},
};
return thirdPartyOrderData[orderEvent];
}
Typescript would complain with:
Error
Property sku does not exist on type { shipmentDate: Date; carrier: "UPS" | "FEDEX"; }
#The solution
So, after some thinking and some research, the best way I found to solve this is as follows.
import { OrderEvent, OrderEventData } from "./order.ts";
export interface ThirdPartyOrderData {
[OrderEvent.Purchase]: {
id: string;
sku: string;
total_price: string;
};
[OrderEvent.Shipped]: {
date_shipped: string;
carrier_brand: string;
};
}
export function buildThirdPartyOrderData<T extends OrderEvent>(
orderEvent: T,
orderEventData: OrderEventData[T]
): ThirdPartyOrderData[T] {
const thirdPartyOrderDataBuilder: {
[K in OrderEvent]: (data: OrderEventData[K]) => ThirdPartyOrderData[K];
} = {
[OrderEvent.Purchase]: (data) => ({
id: data.id,
sku: data.sku,
totalPrice: data.price,
}),
[OrderEvent.Shipped]: (data) => ({
date_shipped: data.shipmentDate,
carrier_brand: data.carrier,
}),
};
if (thirdPartyOrderDataBuilder[orderEvent] == null) {
throw new Error(`Invalid Order Event Type: ${orderEvent}`);
}
return thirdPartyOrderDataBuilder[orderEvent](orderEventData);
}
#The end
By using this data builder pattern, each data
object is correctly typed because of the type:
type DataBuilderType = {
[K in OrderEvent]: (data: OrderEventData[K]) => ThirdPartyOrderData[K];
};
I know for a fact that I will be coming back to use this as reference in the future, and I hope this maybe helps you in whatever you are trying to solve today! 🎉