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 :)