Explicit Errors and Functional Cores
Building Predictable Systems in TypeScript
As software systems grow, they fail in more ways than we expect. Many of those failures are not caused by complex algorithms, but by unclear error handling. Exceptions, global error handlers, and catch-all try/catch blocks often hide what can go wrong, making code harder to read, harder to test, and harder to trust.
This article introduces a set of ideas that help make systems easier to reason about—even as they scale:
treat errors as values, not exceptions ( i.e. never throw)
separate business errors from system failures
keep business logic pure, and push I/O to the edges
These ideas may sound abstract, but they lead to very practical benefits: clearer code, simpler tests, and more predictable behavior in production.
Why Exceptions Make Code Harder to Understand
In TypeScript, a function that throws an exception does not say so in its type signature. When you call it, you cannot tell—just by looking at the code—whether it might fail, or how.
That means failure paths are invisible. You only discover them by reading implementation details, scanning documentation, or learning the hard way in production.
Now consider a different approach: instead of throwing, a function returns a value that represents success or failure.
For example, a function that fetches a user might return:
a
Userwhen successfula
NotFounderror when the user does not existor a
DatabaseErrorwhen something goes wrong with storage
Nothing is thrown. Every outcome is explicit. When you call the function, TypeScript forces you to handle each case. Failure becomes part of the normal control flow, not a surprise.
For example, a function that fetches a user might succeed, fail because the user does not exist, or fail due to a database issue. Each of these cases can be modeled directly in the type system:
type GetUserResult =
| { type: "SUCCESS"; user: User }
| { type: "NOT_FOUND" }
| { type: "DATABASE_ERROR"; error: unknown };
With this contract in place, the implementation does not throw for expected failures:
async function getUserById(id: string): Promise<GetUserResult> {
try {
const user = await db.user.findById(id);
if (!user) {
return { type: "NOT_FOUND" };
}
return { type: "SUCCESS", user };
} catch (error) {
return { type: "DATABASE_ERROR", error };
}
}
Every possible outcome is now explicit. Nothing is hidden, and nothing escapes unexpectedly. More importantly, callers are forced to deal with each case:
const result = await getUserById(userId);
switch (result.type) {
case "SUCCESS":
return renderUser(result.user);
case "NOT_FOUND":
return showNotFoundMessage();
case "DATABASE_ERROR":
logError(result.error);
return showServiceUnavailable();
}
Because the failure modes are encoded in the return type, TypeScript can enforce exhaustive handling.
Not All Errors Mean the Same Thing
One of the most important distinctions to learn is that not all errors are failures.
Some errors are expected business outcomes. For example:
a user does not exist
a job posting is closed
a request is invalid
These are domain errors. They describe valid states of the system and usually map cleanly to user-facing responses like 404 or 400.
Other errors are system problems:
the database is unavailable
the network is down
an unexpected exception occurs
These are infrastructure errors. They are not part of the business logic and should not be exposed directly to users.
When these two kinds of errors are mixed together, systems become confusing and risky. A missing user is not a system failure; a database outage is. Keeping them separate makes intent clear and behavior safer.
In TypeScript, this separation can be expressed directly in types:
type CreateApplicationResult =
| { type: 'CREATED'; application: Application }
| { type: 'JOB_NOT_FOUND' }
| { type: 'JOB_CLOSED' }
| { type: 'INFRASTRUCTURE_ERROR'; error: InfrastructureError };
Anyone reading this type immediately understands what can happen—and what kind of decision each case represents.
The Functional Core and the Imperative Shell
Another common source of complexity is mixing business rules with side effects like database access or HTTP calls. When logic and I/O are tangled together, code becomes hard to test and easy to break.
The functional core, imperative shell pattern solves this by drawing a clear boundary:
The functional core contains pure business logic. It takes inputs and returns outputs. It does not talk to databases or the network.
The imperative shell handles I/O. It calls the database, makes HTTP requests, and translates real-world failures into explicit results.
For example, deciding whether a student profile can be created is pure logic. Actually inserting it into the database is not. By separating these concerns, you get logic that is easier to test and easier to reason about.
Pure functions are predictable: the same input always produces the same output. That predictability is what makes the functional core reliable.
The Functional Core: Pure Business Logic
typeCreateProfileDecision =
| {type:"ALLOWED" }
| {type:"USER_NOT_STUDENT" }
| {type:"PROFILE_ALREADY_EXISTS" };
function decideCreateProfile(user:User):CreateProfileDecision {
if (user.role !=="student") {
return {type:"USER_NOT_STUDENT" };
}
if (user.profileExists) {
return {type:"PROFILE_ALREADY_EXISTS" };
}
return {type:"ALLOWED" };
}
This function is completely deterministic. Given the same User, it will always return the same decision. It is easy to test exhaustively and safe to refactor, because it has no side effects.
The Imperative Shell: Handling I/O and Reality
The imperative shell coordinates the workflow. It performs I/O, translates infrastructure failures into explicit results, and delegates decisions to the functional core.
asyncfunctioncreateStudentProfile(userId:string) {
const userResult =await userRepository.getById(userId);
if (userResult.type !=="SUCCESS") {
return userResult;// domain or infrastructure error
}
const decision = decideCreateProfile(userResult.user);
if (decision.type !=="ALLOWED") {
return decision;// domain decision
}
return profileRepository.insert(userResult.user.id);
}
The shell contains the unavoidable complexity of the real world: databases can fail, networks can be slow, and inserts can error. The functional core remains untouched by these concerns.
Repositories as Boundaries, Not Abstractions for Their Own Sake
Repositories often get a bad reputation as unnecessary abstractions. But their real value is not testability or swapping databases—it is containment.
A repository sits between the application and the database. Its job is to:
perform queries
catch database-specific failures
translate them into explicit, structured errors
This means the rest of the application never deals with raw exceptions or database quirks. It only sees clear outcomes.
Modern ORMs and query builders make this easier by allowing both safe abstractions and raw queries when needed. The key idea is consistency: infrastructure errors are handled in one place.
The Repository: Translating Infrastructure into Meaning
type GetUserResult =
| { type: "SUCCESS"; user: User }
| { type: "NOT_FOUND" }
| { type: "DATABASE_UNAVAILABLE" };
class UserRepository {
async getById(userId: string): Promise<GetUserResult> {
try {
const user = await db.user.findById(userId);
if (!user) {
return { type: "NOT_FOUND" };
}
return { type: "SUCCESS", user };
} catch {
return { type: "DATABASE_UNAVAILABLE" };
}
}
}
This repository performs three essential tasks:
It executes the database query.
It catches database-specific failures and exceptions.
It translates those failures into explicit outcomes meaningful to the rest of the system.
The Application: Consuming Clear Outcomes
const result = await userRepository.getById(userId);
switch (result.type) {
case "SUCCESS":
return renderUser(result.user);
case "NOT_FOUND":
return showNotFound();
case "DATABASE_UNAVAILABLE":
return showServiceUnavailable();
}
Testing Becomes Simpler
When business logic is pure, testing becomes straightforward. Unit tests focus on the functional core, where behavior is deterministic and exhaustive testing is possible.
There is no need for mocks, because there are no side effects to fake. Tests describe what the system should do, not how it talks to dependencies.
Integration tests then cover the imperative shell, using real databases or services. This division keeps tests stable and meaningful, even as the system evolves.
Unit Test: Pure Functional Core
// Functional core: pure business logic
function decideCreateProfile(user: User) {
if (user.role !== "student") return { type: "INVALID_ROLE" };
if (user.profileExists) return { type: "ALREADY_EXISTS" };
return { type: "ALLOWED" };
}
// Unit tests: no mocks required
test("rejects non-students", () => {
const user = { role: "admin", profileExists: false };
expect(decideCreateProfile(user)).toEqual({ type: "INVALID_ROLE" });
});
test("rejects users with existing profiles", () => {
const user = { role: "student", profileExists: true };
expect(decideCreateProfile(user)).toEqual({ type: "ALREADY_EXISTS" });
});
test("allows new students", () => {
const user = { role: "student", profileExists: false };
expect(decideCreateProfile(user)).toEqual({ type: "ALLOWED" });
});
Integration Test: Imperative Shell
// Imperative shell: interacts with repository/database
async function createStudentProfile(userId: string) {
const userResult = await userRepository.getById(userId);
if (userResult.type !== "SUCCESS") return userResult;
const decision = decideCreateProfile(userResult.user);
if (decision.type !== "ALLOWED") return decision;
return profileRepository.insert(userResult.user.id);
}
// Integration test: uses a real database or test DB
test("creates profile when allowed", async () => {
const userId = "123";
const result = await createStudentProfile(userId);
expect(result.type).toBe("SUCCESS");
});
Why This Matters in Production
The benefits of this approach are most visible under failure.
Imagine a database outage during a busy request. Instead of throwing an exception that escapes unpredictably, the repository returns an explicit infrastructure error. The application can respond with a 503, trigger alerts, or retry—deliberately and safely.
Because business logic is pure, partial failures are easier to reason about. Side effects happen in controlled places, and recovery strategies can be designed intentionally.
By making failure explicit, separating business decisions from system problems, and keeping side effects at the edges, you build software that is easier to understand and safer to change.
These patterns are used successfully in languages like Rust and Go, but they work just as well in TypeScript. They help transform error handling from an afterthought into a clear, reliable part of system design—and that clarity pays dividends as systems grow.
Appendix: Key References
Hoare, “Null References: The Billion-Dollar Mistake” (2009) – Hidden failures introduce bugs. link
Moseley & Marks, “Out of the Tar Pit” (2006) – Complexity, not size, drives defects; declarative modeling reduces accidental complexity. link
Spolsky, “The Law of Leaky Abstractions” (2002) – Abstractions must reduce cognitive load without hiding essential details. link
Cantrill, “Lessons in Observability” (2019) – Explicit error reporting improves system reliability. link
Fowler, Patterns of Enterprise Application Architecture (2002) – Layered design and error translation.
Evans, “Domain Events and Explicit Outcomes” (2016) – Explicit domain modeling guides business logic. link
Hughes, “Why Functional Programming Matters” (1989) – Purity improves composability, testability, reasoning. link
Peyton Jones et al., Haskell research (1990s–2010s) – Separate effects from pure computation for maintainability.
ML / Haskell type systems – Encoding failure in types (
Option/Result) reduces defects, improves refactorability.Evans, Domain-Driven Design (2003) – Bounded contexts and explicit domain modeling.
Martin, Clean Architecture (2017) – Functional core / imperative shell, explicit orchestration layers.
Rust –
Result<T,E>enforces exhaustive handling; panics only for unrecoverable errors.Go – Explicit error values; panics reserved for programmer mistakes.
JS/TS + neverthrow – Libraries provide
Result-like types; exceptions are not type-enforced. linkDrizzle ORM / Prisma – Allow raw queries; repository boundaries translate infrastructure failures.
Empirical studies & industry – Mock-heavy tests are brittle; isolating side effects and explicit errors reduces defects (IEEE/ACM, Jane Street, Facebook Flow).



Fantastic breakdown of explicit error handling! The distinction between domain errors and infrastructure errors is something I wish more teams understood early on. I've seen way too many production bugs traced back to business validations getting caught in the same try-catch as database timeouts. Separating them like thisgives you way more control over retry logic and user-facing messags, which matters when things inevitably break at 2am.