TypeScript Meets Functional Programming
How TypeScript's type system turns functional intuitions into compile-time guarantees.
JavaScript can imitate functional programming. TypeScript can enforce it. The difference is not cosmetic — it changes which kinds of mistakes the compiler catches and, therefore, which patterns are actually safe to rely on in a large codebase.
This chapter is a guided tour of the specific TypeScript features that make functional programming practical, and a preview of the shape of the rest of the course.
Why TypeScript is a great FP teaching language
TypeScript was not designed as a functional language. It was designed to add types to JavaScript without breaking the JavaScript mental model. And yet, almost by accident, it ended up with a set of features that map unusually well onto the functional toolbox:
- Structural typing — two types are compatible if their shapes match. No mandatory class hierarchies, no nominal ceremony.
- Discriminated unions — first-class algebraic data types.
- Exhaustiveness checking via
never— the compiler can prove aswitchcovers every case. - Generics with constraints — parametric polymorphism, the backbone of generic FP abstractions.
- Literal types and template-literal types — values can appear in types, enabling precise modeling.
- Type narrowing — control-flow analysis that refines a type as you check it.
- Readonly markers — you can express "this value cannot be mutated" right in the type.
What TypeScript doesn't have natively:
- Higher-kinded types (
F<_>), which Haskell uses to express generic abstractions likeFunctororMonad. Libraries likefp-tssimulate them with clever encodings, but we will not need them. - Pattern matching as a syntax. We use
switchon a discriminant. - A "purity" annotation. We rely on convention and discipline.
We can build everything in this course without the missing pieces. The missing pieces are mostly conveniences.
Functions as first-class values
The atom of the language:
The signature (n: number) => number is itself a type. You can
assign it to a variable, pass it around, name it with type. This
is what makes higher-order functions possible.
Immutability through readonly and as const
TypeScript lets you mark values as unable to be mutated through this binding. This is not a deep guarantee — the underlying object is the same as a mutable one at runtime — but it is enough to catch a huge category of bugs at compile time.
We will use readonly everywhere in this course. It costs nothing
and rules out a vast amount of accidental mutation.
Discriminated unions: algebraic data types in TypeScript
This is the single most powerful FP-enabling feature TypeScript has. A discriminated union is a sum type: "this value is either an A or a B or a C", with a tag that tells you which.
If you add a new case ("cancelled") to LoadState, the compiler
will flag every switch that doesn't handle it. This is exhaustiveness
checking, and it is the mechanism that makes "make illegal states
unrepresentable" actually work.
The never type completes the picture. It is the type with no
values. The compiler will assign never to a variable that has
been narrowed to "impossible".
This pattern — const _exhaustive: never = x in the default case —
is how you turn "I think this switch is complete" into "the
compiler will prove this switch is complete".
Generics: types as parameters
Most useful FP abstractions are generic. They work for any inner type, with no extra runtime cost.
Generics let us write each FP combinator (map, flatMap,
compose, pipe, chain) once, in a way that works for every
shape — and the compiler still tracks the types end-to-end.
A type-driven "Option" preview
We'll spend a whole chapter on this later, but here is a tiny preview of what type-driven design feels like when all three pieces — sum types, generics, exhaustiveness — come together.
Notice:
- There is no
null, noundefined, no exception. - The compiler will not let you read
.valueuntil you've narrowed thekind. mapworks for any inner type without losing precision.
This is the shape of every functional abstraction we will build.
A multi-file preview: separating "what" from "how"
Functional code tends to live in three layers: pure values (data), pure transformations (functions), and an impure shell that drives them. Here is a tiny taste of that shape, spread across three files.
Every function in greet.ts and types.ts is pure: same inputs,
same outputs, no side effects. The only impurity (console.log)
lives in main.ts, the shell. Adding a new language is a one-line
change in types.ts; the compiler will force you to handle it in
formatGreeting thanks to exhaustiveness checking.
This is the architectural pattern the whole course is building toward: pure functional core, thin imperative shell, type system as the integration test.
What we will not lean on
Some quick context-setting before the foundations chapters:
- We will not teach
fp-ts,Effect,Ramda, or any other library as the primary mechanism for any concept. Libraries appear at the end of chapters as optional ecosystem notes. - We will not bring in a framework. No React, no Next, no Express. Every example runs in the browser via almostnode.
- We will not import unnecessary tools. If we need composition, we
write three-line
pipeandcomposehelpers. If we need aResult, we define it.
The reason is pedagogical: it is much easier to understand a
Functor once you've written map for Option, Result, Array,
and Tree yourself than to learn it as "the thing this library does
that has a Foo.map method".
Which TypeScript feature is the most direct enabler of "make illegal states unrepresentable"?
The any type
The strict compiler flag
Discriminated unions with exhaustiveness checking
Interfaces