'Extends' pattern to constrain Generic function inputs

You can play around with the code in this article in the sandbox below:

Typescript Sandbox

Imagine you have a network request with some incoming json:


type HeartRateDataType = {
  timestamp: string;
  medianBPM: number;
  maxBPM: number;
  minBPM: number;
}

// the JSON response will be an array; its type is heartRateDataType[]

This incoming JSON will be used to populate some charting software. It will need to be transformed in a way that makes it usable within the chart software API spec.

If the incoming JSON looks like this:


const heartRateData: HeartRateDataType[] = [{
  timestamp: 'new Date()',
  medianBPM: 80,
  maxBPM: 120,
  minBPM: 60,
},
{
  timestamp: 'new Date()',
  medianBPM: 70,
  maxBPM: 110,
  minBPM: 50,
}]

And we want to pass into the charting software a structure which looks like:


type MinMaxXYGraphObject = {
  name: string;
  x: string[];
  y: number[];
}

const initialDataShape: MinMaxXYGraphObject[] = [
  { name: 'Median', x: [], y: [] },
  { name: 'Min', x: [], y: [] },
  { name: 'Max', x: [], y: [] },
]

We need to populate the above, with the corresponding data from the incoming JSON.

We want create a helper function that will populate the above like:


const populatedDataShape: MinMaxXYGraphObject[] = [
  {
    name: 'Median',
    x: [
      heartRateData[0].timestamp,
      heartRateData[1].timestamp,
      ...etc
    ],
    y: [
      heartRateData[0].medianBPM,
      heartRateData[1].medianBPM,
      ...etc
    ]
  },
  ...etc
]

so we can start with initialDataShape and build upon it:


const populateMinMaxData = (measurements: HeartRateDataType[]): MinMaxXYGraphObject[] =>
  measurements.reduce((acc: MinMaxXYGraphObject[], cur: HeartRateDataType) => {
    acc[0].x.push(cur.timestamp);
    acc[1].x.push(cur.timestamp);
    acc[2].x.push(cur.timestamp);

    acc[0].y.push(cur.medianBPM);
    acc[1].y.push(cur.minBPM);
    acc[2].y.push(cur.maxBPM);

    return acc;
  }, initialDataShape)

Fair enough. Gets the job done.

Intellisense working correctly and all that.

Now bossman comes to you and says, we have a 2nd type of incoming data. Earlier we only had Heart Rate data, now we also have Breath Hold.

Very similar incoming data structure, but instead of BPM, the unit here is MS (milliseconds).


type BreathHoldDataType = {
  timestamp: string;
  medianMS: number;
  maxMS: number;
  minMS: number;
}

Let's imagine in this example that the names of the fields are immutable, we must use them as-is.

We cannot, say, change medianBPM and medianMS to just 'median' to 'normalize' the 2 incoming JSON datasets.

Bossman says, OK, Heart Rate and Breath Hold are similar enough in their nature that we can use the same visual representation in the charting software for both. I.e. we can use the same helper functions to transform the data (in theory).

So we try and re-type populateMinMaxData to allow for an input of either Heart Rate data or Breath Hold data:

failed typing

and..... no good. can't use reduce on a union type. and medianBPM, minBPM and maxBPM don't exist on BreathHold.

How can we make our function Generic so it could take in either set of data?

halfway there

Halfway there. Our function now takes in an array of generic T's (so long as T is either of HeartRateDataType or BreathHoldDataType).

This resolves our reduce issue. However, we will still need to employ type narrowing to take care of the variable assignments inside of the function.

type narrowing

Bit contrived example but hopefully this gets the point across.

The purpose of extending Generic types is to create Generic functions which have variable inputs/outputs as far as the types of things they can take in and spit out.

By using extends, we are constraining the potential list of options of what these generic inputs can be.