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:

order.ts
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;
}
ts
integration.ts
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];
}
ts

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:

order.ts
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;                        
}
ts
integration.ts
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];
}
ts

Typescript would complain with:

The solution

So, after some thinking and some research, the best way I found to solve this is as follows.

integration.ts
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);
}
ts

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];
};
ts

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! 🎉