I’ve been thinking about something that happens to every developer. You write code, you test it, and it works perfectly. Then you deploy it, and things break in ways you never imagined. A database query fails silently. An API call returns an unexpected shape. An error gets thrown, but your type system had no idea it was possible. The types said everything was fine, but runtime told a different story. This gap between what TypeScript knows at compile time and what actually happens is what keeps me up at night. It’s why I’ve spent so much time exploring ways to close that gap completely. Today, I want to show you a method that has fundamentally changed how I build reliable systems.
Let’s talk about building APIs where your types don’t lie. Where if the code compiles, you have a high degree of confidence it will work. This isn’t about adding more tests, though those are important. This is about designing your program so that impossible states are literally impossible to represent in your code. The traditional approach in Node.js and TypeScript often involves promises, try-catch blocks, and hoping for the best. What if we could do better?
What if every possible failure was documented in the type signature of your function?
I started with a simple question: how can I make my Fastify API endpoints as robust as my type definitions suggest they should be? The answer led me to a library called Effect-TS. It applies principles from functional programming to TypeScript, giving us tools to handle errors, async operations, and dependencies in a way that’s both type-safe and composable. Think of it as giving TypeScript superpowers it never knew it needed.
We’ll build a user management API together. You’ll see how to define operations that can fail in specific, documented ways. First, we need to set up our project. Create a new directory and initialize it.
npm init -y
npm install effect fastify @fastify/cors
npm install -D typescript @types/node tsx
Configure TypeScript strictly. This is non-negotiable for what we’re doing.
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true
}
}
The core idea in Effect-TS is the Effect type. It’s not a promise. It’s a description of a program that may succeed, fail, or require some services to run. It has three parts: Effect<Success, Error, Requirements>. The Error part is crucial. It’s a list of every possible thing that can go wrong. Let’s define what can go wrong in our app.
// errors/app-errors.ts
import { Data } from "effect"
export class DatabaseError extends Data.TaggedError("DatabaseError")<{
message: string
cause?: unknown
}> {}
export class UserNotFoundError extends Data.TaggedError("UserNotFoundError")<{
userId: string
}> {}
We just created two error types. UserNotFoundError is not a generic error. It’s a specific, structured event. Our type system will now track when this error can occur. Now, let’s define a simple user model using Effect’s schema library for validation.
// domain/user.ts
import { Schema } from "@effect/schema"
export class User extends Schema.Class<User>("User")({
id: Schema.String,
email: Schema.String,
name: Schema.String
}) {}
const EmailSchema = Schema.String.pipe(
Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)
)
export class CreateUserRequest extends Schema.Class<CreateUserRequest>("CreateUserRequest")({
email: EmailSchema,
name: Schema.String.pipe(Schema.minLength(1))
}) {}
Notice the email validation is part of the type. A CreateUserRequest instance, by definition, has a valid email format. Invalid data cannot be represented. This moves validation from runtime to the type boundary. Now, how do we actually work with these types? Let’s create a service.
Have you ever passed a database connection or a logger to a function and wondered if it was undefined?
Effect-TS has a built-in dependency injection system called Context. You define a service tag.
// infrastructure/database.ts
import { Context, Effect } from "effect"
import { DatabaseError } from "../errors/app-errors"
export class DatabaseService extends Context.Tag("DatabaseService")<
DatabaseService,
{
readonly query: <T>(
sql: string,
params?: any[]
) => Effect.Effect<T[], DatabaseError>
}
>() {}
We’ve declared that a DatabaseService exists. It has a query method. That method returns an Effect that either gives us an array of T or fails with a DatabaseError. Nothing else. No unexpected exceptions. Now, let’s write a repository function using this service.
// infrastructure/user-repository.ts
import { Effect, pipe } from "effect"
import { DatabaseService } from "./database"
import { UserNotFoundError } from "../errors/app-errors"
import { User } from "../domain/user"
export const findUserById = (userId: string): Effect.Effect<User, UserNotFoundError | DatabaseError, DatabaseService> => {
return pipe(
DatabaseService,
Effect.flatMap((db) =>
db.query<User>("SELECT * FROM users WHERE id = $1", [userId])
),
Effect.flatMap((results) =>
results.length > 0
? Effect.succeed(results[0])
: Effect.fail(new UserNotFoundError({ userId }))
)
)
}
Look at the return type. Effect<User, UserNotFoundError | DatabaseError, DatabaseService>. It’s a complete contract. This function needs a DatabaseService to run. It will return a User if successful. It can fail in two specific, typed ways: the user wasn’t found, or the database query itself failed. You cannot call this function without providing a database, and you must handle both error cases. The type system enforces it.
How do we provide the database service? We create a Layer.
// infrastructure/database-layer.ts
import { Layer, Effect } from "effect"
import { Pool } from "pg"
import { DatabaseService, DatabaseError } from "./database"
export const DatabaseServiceLive = Layer.effect(
DatabaseService,
Effect.gen(function* (_) {
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
// Resource management is built-in
yield* _(Effect.addFinalizer(() => Effect.promise(() => pool.end())))
return {
query: (sql, params) =>
Effect.tryPromise({
try: () => pool.query(sql, params).then(r => r.rows),
catch: (cause) => new DatabaseError({ message: "Query failed", cause })
})
}
})
)
The layer manages the lifecycle of the database pool. It’s created and cleaned up automatically. Now, let’s wire this into a Fastify route. This is where the magic becomes visible.
// http/routes.ts
import { FastifyInstance } from "fastify"
import { Effect, pipe } from "effect"
import { findUserById } from "../infrastructure/user-repository"
import { DatabaseServiceLive } from "../infrastructure/database-layer"
import { UserNotFoundError, DatabaseError } from "../errors/app-errors"
export const registerUserRoutes = (app: FastifyInstance) => {
app.get("/users/:id", async (request, reply) => {
const { id } = request.params as { id: string }
// Build our program
const program = findUserById(id)
// Run it with the required services
const result = await Effect.runPromise(
program.pipe(Effect.provide(DatabaseServiceLive))
)
// But wait, this will throw if the Effect fails!
// We need to handle the error cases.
return result
})
}
The code above is incomplete and problematic. It will crash if the user is not found. Effect programs don’t throw on failure by default when run. We need to explicitly handle the error channel. Let’s fix that.
app.get("/users/:id", async (request, reply) => {
const { id } = request.params as { id: string }
const program = pipe(
findUserById(id),
Effect.match({
onSuccess: (user) => ({ status: "success", data: user }),
onFailure: (error) => {
// We can now match on the exact error type
if (error._tag === "UserNotFoundError") {
return { status: "error", code: "USER_NOT_FOUND", userId: error.userId }
}
if (error._tag === "DatabaseError") {
// Log the internal error, send a generic message
console.error("Database error:", error.cause)
return { status: "error", code: "INTERNAL_SERVER_ERROR" }
}
// The type system ensures we've handled all known errors.
// A new error type would cause a compile-time error here.
return { status: "error", code: "UNKNOWN_ERROR" }
}
})
)
const result = await Effect.runPromise(
program.pipe(Effect.provide(DatabaseServiceLive))
)
// Send appropriate HTTP status
if (result.status === "success") {
return reply.code(200).send(result)
}
if (result.code === "USER_NOT_FOUND") {
return reply.code(404).send(result)
}
return reply.code(500).send(result)
})
Now we have a fully typed error flow. The HTTP handler knows every possible outcome. There are no hidden exceptions. The frontend team gets a stable, documented set of error codes. This approach scales beautifully. Need to add a new error case, like InvalidInputError? Add it to the Effect’s error type. Every place that calls your function will get a type error until they handle the new case.
What about complex operations, like creating a user only if the email doesn’t exist, within a database transaction?
// services/user-service.ts
import { Effect, pipe } from "effect"
import { DatabaseService } from "../infrastructure/database"
import { CreateUserRequest, User } from "../domain/user"
import { DatabaseError, DuplicateEmailError } from "../errors/app-errors"
const checkEmailExists = (email: string): Effect.Effect<boolean, DatabaseError, DatabaseService> => {
return pipe(
DatabaseService,
Effect.flatMap(db => db.query<{count: number}>("SELECT COUNT(*) as count FROM users WHERE email = $1", [email])),
Effect.map(results => results[0].count > 0)
)
}
export const createUser = (request: CreateUserRequest): Effect.Effect<User, DuplicateEmailError | DatabaseError, DatabaseService> => {
return pipe(
checkEmailExists(request.email),
Effect.flatMap(emailExists =>
emailExists
? Effect.fail(new DuplicateEmailError({ email: request.email }))
: pipe(
DatabaseService,
Effect.flatMap(db =>
db.query<User>(
"INSERT INTO users (email, name) VALUES ($1, $2) RETURNING *",
[request.email, request.name]
)
),
Effect.map(results => results[0])
)
)
)
}
The logic is clear in the types: this can fail because the email is a duplicate, or because of a database issue. The happy path produces a User. You can chain these effects, retry them on failure, or set timeouts, all within the same type-safe context.
This method requires a shift in thinking. You model your program as a data structure first—a description of what should happen. Then you provide the real-world resources (database, logger, config) and run it. The benefit is immense: local reasoning, easy testing by providing mock services, and compiler-checked error handling.
Does it add complexity? Yes, initially. You trade the simplicity of throw new Error() for the safety of a type-checked error channel. For a small script, it might be overkill. For a backend service that needs to be reliable and maintainable over years, it’s an investment that pays off every day. You spend less time debugging production issues and more time confidently adding features.
Start small. Try rewriting a single, complex endpoint in this style. Feel the confidence when the type checker says your code is correct. Share your experience in the comments below—I’d love to hear how it goes for you. If this approach to building robust APIs makes sense, please like and share this article so other developers can close the gap between their types and their runtime, too.
As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva