Capstone — A Functional Data Pipeline
Combine everything from the course into a single, multi-file, end-to-end functional program.
This chapter is a small project that ties together almost every idea from the course. We'll build a complete program that:
- Parses raw input strings into a typed domain.
- Validates them with
Resultso all errors are first-class. - Transforms the valid records with a
pipeof pure functions. - Aggregates the data using
reduce. - Reports the output through a tiny imperative shell.
There is no library involved; just plain TypeScript.
The scenario
We're given a list of raw "sale" rows from a CSV-like source:
"2024-09-01,A,3,12.50"
"2024-09-01,B,1,9.99"
"2024-09-02,A,2,12.50"
"bogus row"
"2024-09-02,C,4,2.00"Each well-formed row is date,sku,qty,price. We want to:
- skip bogus rows but report them,
- compute the total revenue per SKU,
- compute the date with the highest revenue,
- print a small report.
We will solve it as a chain of pure functions; the shell will only hold the raw data and print the final report.
The core
Run the example. Every function in parse.ts, transform.ts, and
pipeline.ts is pure. Every type in types.ts is immutable.
Every error is a value. The only impure code is the four
console.logs in main.ts — and that's exactly where it should
be: the edge of the program.
What patterns from the course you just used
Let's audit. In this single small program you used:
- Pure functions (
parseRow,withRevenue,sumByKey,maxByValue,partition,runReport). - Immutability (
readonlyeverywhere; spread updates). - Discriminated unions (
Result<E, A>). - Type-driven design (
SaleandPricedtypes make illegal states unrepresentable;Resultmakes failures part of the signature). - Generic abstractions (
sumByKeyworks for any element type). - Composition (
runReportispartition → map(withRevenue) → sumByKey → maxByValue). - Functional core, imperative shell (
pipeline.tsis the pure core;main.tsis the shell).
That's the entire course in one screen of code. None of it required a library or a framework. It only required thinking in the functional style.
Extending the capstone
Try these on your own:
- Add a filter for sales below a minimum price before
aggregating. Where in the pipeline does it go? (Hint: between
withRevenueandsumByKey.) - Add a second report: top 3 SKUs by revenue. (Hint: sort the
byProductentries.) - Make the pipeline async: change
parseRowto aTaskResult<string, Sale>and have the shell run them in parallel. Notice how little the core changes — most of the work is on the boundary. - Add branded types: make
SKUa brandedstring, and haveparseRowproduce aSalewhoseskufield has that brand. Now any function that takes anSKUis guaranteed to receive a parsed one.
Each extension is a small, mostly-pure change. That's the dividend of building this way.
Looking at the capstone, what makes the program as a whole easy to test and evolve?
It uses ES modules
Every .ts file is small
All business logic lives in pure functions that take data and return data, with side effects confined to a tiny shell — so each piece can be tested with plain inputs, and the parts you'll want to change live where you expect them
The program is short