Skip to main content

TS Ep 70: Building a REST API with Effect

Rachmat Hidayat
Author
Rachmat Hidayat
Learn & sharing insights on TypeScript, Go, Kubernetes, DevOps, DevSecOps, SRE, Platform Engineering, AI/ML Engineering, and MLOps.
typescript - This article is part of a series.
Part 70: This Article
Theory is useless without execution. It is time to assemble the Voltron of Functional Programming. We are building a fully typed, perfectly safe, highly concurrent REST API.

TL;DR (Quick Summary)
#

  • The Goal: Combine Schema (Validation), Context (Dependency Injection), Layer (Wiring), and catchTag (Error Handling) into a real-world Express server.
  • @effect/platform: The official Effect ecosystem library for building HTTP servers (Node.js, Bun, Cloudflare Workers) natively in the Effect paradigm.
  • The Router: Effect provides HttpRouter, allowing you to map HTTP methods to Effect pipelines.
  • The Edge: We mount the Effect Router onto a native Node.js/Express server and run it using NodeServer.listen.

1. Introduction: The Final Architecture
#

Over the last 14 episodes, we transformed how we write TypeScript. We abandoned classes, mutations, try/catch, and global variables.

Today, we build a POST /users endpoint. Our architecture will look like this:

  1. The API Layer: Receives the HTTP request, validates the JSON payload via Schema.
  2. The Business Layer: Hashes the password and delegates to the Database Service.
  3. The Data Layer: Saves the user to the database, simulating network delays.
  4. The App Edge: Provides all dependencies via Layer and starts the Express server.

Let’s begin.


2. Step 1: Dependencies and Schemas
#

First, install the platform libraries.

npm install @effect/platform @effect/platform-node @effect/schema

Now, define our Validation Schema and our Discriminated Errors.

import { Schema } from "@effect/schema";
import { Data } from "effect";

// 🟢 1. Input Validation
export const CreateUserRequest = Schema.Struct({
  email: Schema.String.pipe(Schema.nonEmptyString()),
  password: Schema.String.pipe(Schema.minLength(8))
});

// 🟢 2. Domain Errors
export class UserAlreadyExistsError extends Data.TaggedError("UserAlreadyExists")<{
  readonly email: string;
}> {}

export class DatabaseError extends Data.TaggedError("DatabaseError")<{
  readonly message: string;
}> {}

3. Step 2: The Database Service (Context & Layer)
#

We define our DatabaseService interface, create a Context.Tag, and build a mock Layer for it.

import { Context, Effect, Layer } from "effect";

// 🟢 1. The Interface
export interface DatabaseService {
  readonly saveUser: (email: string) => Effect.Effect<void, UserAlreadyExistsError | DatabaseError>;
}

// 🟢 2. The Tag (Injection Token)
export const DatabaseTag = Context.GenericTag<DatabaseService>("DatabaseService");

// 🟢 3. The Implementation Layer
export const DatabaseLive = Layer.succeed(DatabaseTag, {
  saveUser: (email) => Effect.sync(() => {
    console.log(`[DB] Saving user ${email}...`);
    // Simulate checking if user exists
    if (email === "test@test.com") {
      return Effect.fail(new UserAlreadyExistsError({ email }));
    }
    return Effect.succeed(undefined);
  })
});

4. Step 3: The API Route
#

We use @effect/platform to define our route. This is where the magic happens. We extract the JSON body, validate it using Schema, and call the Database Service.

import { HttpRouter, HttpServerRequest, HttpServerResponse } from "@effect/platform";
import { Effect } from "effect";

// 🟢 The Route Handler
const createUserRoute = HttpRouter.post("/users", Effect.pipe(
  // 1. Get the HTTP Request
  HttpServerRequest.HttpServerRequest,

  // 2. Parse the JSON body against our Schema!
  Effect.flatMap(req => req.schemaBodyJson(CreateUserRequest)),

  // 3. Ask for the DatabaseService
  Effect.flatMap(payload => Effect.pipe(
    DatabaseTag,
    Effect.flatMap(db => db.saveUser(payload.email))
  )),

  // 4. If everything succeeded, return 201 Created
  Effect.map(() => HttpServerResponse.empty({ status: 201 })),

  // 5. ERROR HANDLING: Match specific errors to HTTP Status Codes
  Effect.catchTags({
    ParseError: (err) => Effect.succeed(HttpServerResponse.text(String(err), { status: 400 })),
    UserAlreadyExists: (err) => Effect.succeed(HttpServerResponse.text(`User ${err.email} exists`, { status: 409 })),
    DatabaseError: () => Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 }))
  })
));

// 🟢 Create the Router
const MainRouter = HttpRouter.empty.pipe(
  HttpRouter.append(createUserRoute)
);

Take a moment to admire createUserRoute. It is mathematically perfect. There is no try/catch. The validation ParseError and the database UserAlreadyExistsError are caught perfectly using Effect.catchTags. If you forget to handle an error, the code will not compile!


5. Step 4: The Edge (Booting the Server)
#

Finally, we wire our MainRouter into a Node.js Express server using the platform adapter, provide our DatabaseLive layer, and run the program!

import { NodeHttpServer } from "@effect/platform-node";
import { Effect, Layer } from "effect";
import { createServer } from "node:http";

// 1. Convert the Effect Router into a native HTTP App
const HttpApp = HttpRouter.toHttpApp(MainRouter);

// 2. Create the Node.js Server Layer (Running on Port 3000)
const ServerLive = NodeHttpServer.server.layer(() => createServer(), { port: 3000 });

// 3. Wire everything together!
const AppLive = Layer.mergeAll(
  ServerLive,     // The Web Server
  DatabaseLive    // The Database Service
);

// 4. Run the application
const runnable = Effect.pipe(
  HttpApp,
  Effect.provide(AppLive) // Inject the massive layer graph!
);

// Boot!
Effect.runFork(runnable);
console.log("🚀 Server running on http://localhost:3000");

6. Conclusion: The Functional Promise Delivered
#

Over the course of this module, we have completely rewired how we think about software engineering.

You learned that:

  • Immutability eliminates shared-state bugs.
  • Algebraic Data Types eliminate impossible states.
  • Option and Either eliminate null and thrown exceptions.
  • Pipe and Flow eliminate the Pyramid of Doom.
  • Effect Fibers eliminate resource-wasting native Promises.
  • Schema eliminates I/O corruption.
  • Context eliminates global singletons.

When you combine all of these into a single architecture, you achieve something incredibly rare in the software industry: Sleep.

You can deploy your backend on a Friday afternoon, knowing with mathematical certainty that your dependency graph is resolved, your errors are caught, and your types are bulletproof.

Thank you for joining me on this 15-part journey into the Effect-TS ecosystem.

Keep shipping pure functions!

typescript - This article is part of a series.
Part 70: This Article