Modular Pure Functions With Typescript Generics

Intro

So I've recently been experimenting with functional programming, and being a typescript fanatic, I was naturally drawn to fp-ts.

A key thing with functional programming is the idea of data pipelines, a series of pure functions that transform the shape or value of some data you have. A problem that arises, however, is in defining these functions.

For simple transformations, inlining the transformation functions is nice and convenient.

import {pipe} from 'fp-ts/function';
import * as A from 'fp-ts/Array';

const arr = [1, 2, 3, 4, 5];

pipe(arr, A.map((num) => num**2));

Our problem

However, transformations are more complex in practice. You will have pipelines of functions that belong to different responsibilities that shouldn't all be inlined. Say you require the following transformation fromInitial to Final.

type CompanyName = string
type CompanyTotal = number

type Initial = {
  companyName: CompanyName;
  tool: {
    toolAmount: number;
    toolPrice: number;
  }
}[]

type Final = Record<CompanyName, CompanyTotal>

With real data, this is what the input-output should be:

const records: Initial = [
    {
      companyName: 'Company 1',
      tool: {
        toolAmount: 10,
        toolPrice: 100
      }
    },
    {
      companyName: 'Company 3',
      tool: {
        toolAmount: 30,
        toolPrice: 300
      }
    },
    {
      companyName: 'Company 3',
      tool: {
        toolAmount: 40,
        toolPrice: 400
      }
    },
]

const final: Final = {
  "Company 1": 1000,
  "Company 3": 25000,
}

A possible data pipeline solution would be as below.

import {pipe, flow} from 'fp-ts/function';
import * as A from 'fp-ts/Array';
import * as R from 'fp-ts/Record';
import {group} from 'radash'

const flattenRecords = A.map(({companyName, tool}) => ({companyName, toolName: tool.toolName, toolAmount: tool.toolAmount, toolPrice: tool.toolPrice}))   

const groupByCompanyName = (records) => group(records, (record) => record.companyName)

const calculateCompanyTotal = flow(
  R.map(flow(
      A.map(({toolAmount, toolPrice}) => toolAmount * toolPrice),
      A.reduce(0, (acc: number, cur: number) => acc + cur)
    )
  ),
)

const result = pipe(records, flattenRecords, groupByCompanyName, calculateCompanyTotal)

Note that the main pipeline puts records through flattenRecords, groupByCompanyName and calculateCompanyTotal. The implementation of these intermediary functions should not be inlined within the main pipeline as its responsibility is to run the functions, not know the implementation of them. Instead, the main pipeline adheres to the function names as an interface.

The above is good. Except for one problem. Type safety?!?!?!.

No type safety?!?!

We coulddd add explicit types like below.

type FlattenedRecords = {
  companyName: CompanyName;
  toolName: string;
  toolAmount: number;
  toolPrice: number;
}

const groupByCompanyName = (records: FlattenedRecords[]) => group(records, (record) => record.companyName)

type GroupedRecords = Record<CompanyName, FlattenedRecords[]>

const calculateCompanyTotal = (records: GroupedRecords) => pipe(
  records,
  R.map(flow(
      A.map(({toolAmount, toolPrice}) => toolAmount * toolPrice),
      A.reduce(0, (acc: number, cur: number) => acc + cur)
    )
  ),
)

However, this is extremely rigid. Say you change the location of a function, and the argument it now takes is different, typescript will complain! And imagine making a type for every intermediary function! Insanity!

Modularizing our function dependencies

Instead, we can notice something about our intermediary functions. groupByCompanyName does indeed come before flattenRecords (which returns FlattenedRecords[]), however, the only thing it works on is record.companyName. Hmm. The interface segregation rule is a SOLID principle that says a class shouldn't implement methods it doesn't need and adapted to functional programming, a function shouldn't take more arguments than it needs.

Let's remove the unneeded dependencies

const groupByCompanyName = <CompanyDetails extends {companyName: CompanyName}>(records: CompanyDetails[]) => group(records, (record) => record.companyName)

const calculateCompanyTotal = <CompanyDetails extends {toolAmount: number, toolPrice: number}>(records: Record<CompanyName, CompanyDetails[]>) => pipe(
  records,
  R.map(flow(
      A.map(({toolAmount, toolPrice}) => toolAmount * toolPrice),
      A.reduce(0, (acc: number, cur: number) => acc + cur)
    )
  ),
)

Beautiful! Just like that, we have specified JUST the dependencies that each function needs. We can now move around these functions wherever they fit in properly. e.g. we could perform more transformations after flattenRecords and only then call groupByCompanyName, and as long as companyName is still present in each object of the list, Typescript will be happy as a bee! We won't need to re-specify any types!

Conclusion

When using intermediary functions in a data pipeline, using generics can help modularize your functions and make them extensible and remove the need for repetitive and rigid type definitions.

Thanks for reading :)