Dataslope logoDataslope

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 a switch covers 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 like Functor or Monad. Libraries like fp-ts simulate them with clever encodings, but we will not need them.
  • Pattern matching as a syntax. We use switch on 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:

Code Block
TypeScript 5.7

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.

Code Block
TypeScript 5.7

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.

Code Block
TypeScript 5.7

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".

Code Block
TypeScript 5.7

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.

Code Block
TypeScript 5.7

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.

Code Block
TypeScript 5.7

Notice:

  • There is no null, no undefined, no exception.
  • The compiler will not let you read .value until you've narrowed the kind.
  • map works 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.

Code Block
TypeScript 5.7

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 pipe and compose helpers. If we need a Result, 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".


QuestionSelect one

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

On this page