if statement checking three different boolean flags just to figure out what state your application is in, your domain model is broken. Algebraic Data Types fix this.TL;DR (Quick Summary)#
- The Problem: Modeling state with multiple optional boolean flags (e.g.,
isLoading,isError,isSuccess) creates “impossible states” (e.g., being in a loading state and an error state simultaneously). - Product Types (AND): Standard objects and interfaces where a type contains Property A AND Property B.
- Sum Types (OR): Discriminated Unions in TypeScript, where a type is exactly State A OR State B.
- Exhaustive Matching: By using the
nevertype in aswitchstatement, the TypeScript compiler will physically block your code from compiling if you forget to handle a specific state. - Effect-TS Connection: The core of Effect-TS is built entirely on ADTs to guarantee that errors and asynchronous boundaries are handled exhaustively.
1. Introduction: The Disease of Boolean Flags#
The vast majority of bugs in modern front-end and back-end systems do not come from bad algorithms; they come from poorly modeled domain state.
Consider a standard React component or a Node.js data fetcher. The traditional way developers model an asynchronous request is by throwing boolean flags into an object.
// 🔴 DANGER: Poorly modeled domain state
interface FetchState {
isLoading: boolean;
data: string | null;
error: Error | null;
isSuccess: boolean;
isError: boolean;
}
// What does this state mean?
const impossibleState: FetchState = {
isLoading: true,
data: "User Data",
error: new Error("Network Timeout"),
isSuccess: true,
isError: true
};Look closely at impossibleState. The application is simultaneously loading, has successful data, and has a fatal network error.
If this object is passed into a rendering function or a downstream service, the service has to write incredibly complex, defensive if/else spaghetti logic just to figure out what to do. The compiler cannot help you because, according to TypeScript, impossibleState is a perfectly valid FetchState.
2. What are Algebraic Data Types (ADTs)?#
Algebraic Data Types (ADTs) are a concept from Functional Programming (specifically languages like Haskell and Scala) that allow developers to model data structures using algebraic operations: Sums and Products.
Product Types (The “AND” Operation)#
A Product Type is a data structure that combines multiple types together. In TypeScript, this is your standard interface or type alias. It is called a “Product” because the total number of possible states is the multiplication of the possible values of its fields.
// This is a Product Type.
// A User has a 'name' AND an 'age'.
type User = {
name: string;
age: number;
};Sum Types (The “OR” Operation)#
A Sum Type is a data structure that represents a value that can be exactly one of several different types. In TypeScript, this is achieved using Unions (|). It is called a “Sum” because the total number of possible states is the addition of the states in the union.
// This is a Sum Type.
// The status is exactly "success" OR "error" OR "loading".
type Status = "success" | "error" | "loading";By combining Product Types and Sum Types, we create Discriminated Unions, the ultimate weapon against impossible states.
3. Step-by-Step: Modeling with Discriminated Unions#
Let’s fix our broken FetchState by remodeling it using an Algebraic Data Type.
Step 1: Define the Individual States (Product Types)#
Instead of one massive interface with optional fields, we define a separate interface for every mutually exclusive state our system can be in.
Crucially, we add a discriminant property (usually called _tag, type, or status) to each interface. This must be a literal string type.
// 🟢 The specific, mutually exclusive states
interface StateLoading {
readonly _tag: "Loading";
}
interface StateSuccess {
readonly _tag: "Success";
readonly data: string; // Data ONLY exists in the Success state
}
interface StateError {
readonly _tag: "Error";
readonly error: Error; // Error ONLY exists in the Error state
}Step 2: Create the Sum Type#
We union these individual Product Types together into a single Sum Type.
// 🟢 The Sum Type (Discriminated Union)
type FetchStateADT = StateLoading | StateSuccess | StateError;Step 3: Pattern Match on the Discriminant#
Now, when a function receives the FetchStateADT, it checks the _tag property. Because of TypeScript’s advanced control flow analysis, once the _tag is checked, the compiler automatically narrows the type.
function handleFetch(state: FetchStateADT) {
if (state._tag === "Loading") {
// TypeScript KNOWS this is StateLoading.
// state.data is a compiler error here!
console.log("Loading...");
} else if (state._tag === "Success") {
// TypeScript KNOWS this is StateSuccess.
console.log("Data received:", state.data);
} else {
// TypeScript KNOWS this must be StateError.
console.error("Failed:", state.error.message);
}
}Notice what happened. It is now physically impossible to construct a state that is both loading and has data. The compiler simply will not allow it. We have eliminated an entire category of bugs without writing a single line of unit test code.
4. The Power of Exhaustiveness Checking#
What happens if a junior developer adds a fourth state to our ADT a year from now?
interface StateTimeout {
readonly _tag: "Timeout";
readonly duration: number;
}
type FetchStateADT = StateLoading | StateSuccess | StateError | StateTimeout;If they add this state, every switch statement or if/else block in your massive enterprise application that handles FetchStateADT is suddenly missing a branch.
In standard JavaScript, this causes a runtime crash. In TypeScript, we can force a compile-time failure using the never type.
function renderUI(state: FetchStateADT): string {
switch (state._tag) {
case "Loading":
return "Spinner";
case "Success":
return `<div>${state.data}</div>`;
case "Error":
return `<div class="error">${state.error.message}</div>`;
default:
// EXHAUSTIVENESS CHECK!
// If all cases are handled, 'state' is narrowed to type 'never'.
// If 'Timeout' is added but not handled, 'state' falls through as 'StateTimeout'.
// Assigning 'StateTimeout' to 'never' causes a loud Compiler Error!
const _exhaustiveCheck: never = state;
return _exhaustiveCheck;
}
}When the junior developer adds StateTimeout, the compiler will immediately highlight the default block in red and say:
TS2322: Type 'StateTimeout' is not assignable to type 'never'.
This gives you mathematical confidence that your application correctly handles every single possible state across the entire codebase.
5. Why ADTs are the Foundation of Effect-TS#
If you have ever looked at the core signature of an Effect, you will see Effect<A, E, R>.
An Effect is fundamentally an Algebraic Data Type. It is a highly advanced Discriminated Union that represents either a Success (A), an Expected Error (E), or a requirement for Context (R).
Because the Effect runtime is built entirely on ADTs, it can guarantee exhaustiveness. If your database service returns an Effect<User, DatabaseError, DatabaseService>, the TypeScript compiler will physically prevent you from returning the HTTP response until you have explicitly pattern-matched and handled the DatabaseError.
Without ADTs, Effect-TS would just be another Promise library. With ADTs, it is a bulletproof functional runtime.
6. Troubleshooting & Common Errors#
When implementing ADTs, developers often run into a few specific TypeScript quirks.
Error 1: Missing the literal type#
TS2367: This condition will always return 'false' since the types 'string' and '"Success"' have no overlap.The Cause: When defining your interface, you used string instead of a string literal for the discriminant.
// 🔴 Bad: _tag is any string. The compiler cannot discriminate.
interface StateSuccess {
readonly _tag: string;
}
// 🟢 Good: _tag is a literal type.
interface StateSuccess {
readonly _tag: "Success";
}Error 2: The never assignment error#
TS2322: Type 'StateTimeout' is not assignable to type 'never'.The Cause: You successfully set up an exhaustiveness check, but you forgot to handle one of the cases in your switch or if/else block.
The Fix: Add the missing case "Timeout": block above the default block. This error is actually a feature; it proves your ADT is protecting you!
Summary & Next Steps#
In this episode:
- We identified how optional boolean flags create “impossible states”.
- We learned that Product Types (Interfaces) act as “AND” operations.
- We learned that Sum Types (Unions) act as “OR” operations.
- We combined them to build Discriminated Unions (ADTs).
- We implemented Exhaustiveness Checking using the
nevertype to mathematically prove our switch statements are complete.
We are now modeling state perfectly. But how do we model the absence of state? In standard JavaScript, we use null or undefined (often called the billion-dollar mistake).
In Episode 59: Monadic Patterns (Option & Either), we will learn how to completely eliminate null and undefined from our functional architecture!

