Create API with Effect TS

Learn how to build an API using the Effect library in TypeScript, focusing on dependency injection and functional programming principles.

27 mai 2024

Published

Hugo Mufraggi

Author

5 min read
Create API with Effect TS

Create API With Effect Ts

It is the third article; you can find the first two here:

  • Manage your database migration workflow with Effect
  • Create your first Repository with Effect

I’ll show you how to build an API using the Effect product package in this part.

Objectives

This article's objectives are to create two endpoints: one to create a Task and one to get by ID. We will use the dependency injection provided by Effect.

Dependency Injection

Dependency injection (DI) is a software design pattern where components (such as classes or modules) are given their dependencies rather than creating or looking for dependencies. This pattern promotes modularity, reusability, and easier testing by decoupling dependent components from their dependencies.

Practice

Type Definition

First, I’ll define my type using Effect and provide its package, which is similar to Zod. I will reuse the definition of my task inside my SQL repository.

import * as S from "@effect/schema/Schema"

export const Task = S.Struct({
    id: S.UUID,
    task_name: S.String,
    task_description: S.String,
    status: S.String,
    created_at: S.DateFromSelf,
    updated_at: S.DateFromSelf
})

export const InsertTaskSchema = Task.pipe(S.omit("id", "created_at", "updated_at"))
export type TaskInsert = S.Schema.Type<typeof InsertTaskSchema>
export type TaskSchema = S.Schema.Type<typeof Task>
  • Task contains all the fields for my task definition.
  • InsertTaskSchema contains only task_name, task_description, and status. I omit id, created_at, and updated_at.
  • TaskSchema is the type defined by my Task struct.

I will use TaskInsert for field validation within my request body. This will be used by the insert function in my repository.

My TaskSchema is what I will return for my create and get by ID functions.

Routing

I start by creating a folder structure inside src: one folder named api containing two subfolders named Health and Tasks.

Next, I create three files for my health check and my two endpoint definitions.

mkdir src/api
mkdir src/api/health
mkdir src/api/tasks

touch src/api/health/health.route.ts
touch src/api/tasks/getById.route.ts
touch src/api/tasks/insertTask.route.ts

Health check

Quick return by the health check def. The interesting part is how the effect allows disabling the logger for some specific endpoint. I will explain more about the insert and get by ID for the rest.

import * as Http from "@effect/platform/HttpServer"

export const health = Http.router.get(
    "/health",
    Http.response.text("ok").pipe(
        Http.middleware.withLoggerDisabled
    )
)

Insert

Like the health check, Effect uses the Http platform package to define a single API endpoint.

In our case, I will create a POST endpoint at /tasks and associate an effect generator with this endpoint.

export const insertTask = Http.router.post(
    "/tasks",
    Effect.gen(function* (_) {
    //TODO
    }

The first part of the generator is retrieving the repository, extracting the data from the request body, and then inserting the tasks using the repository.

Effect.gen(function* (_) {
             const repository = yield* InitTaskRepository
         const data = yield* Http.request.schemaBodyJson(InsertTaskSchema)
         const effect = repository.insert(data)
  }

Finally, I will use pattern matching to manage the different states of my effect.

const res = Effect.match(effect, {
                onFailure(e: ResultLengthMismatch | SqlError | ParseError): ServerResponse {
                    return HttpServer.response.unsafeJson(
                        {message: e},
                        {status: 404},
                    )
                }, onSuccess(value: TaskSchema): ServerResponse {
                    return HttpServer.response.unsafeJson(
                        value,
                        {status: 201},
                    )
                },
        });
    return yield* res;
    }

Easily, I’ll handle the two main cases: whether the insert has failed or succeeded. And that’s it. It is quite similar to typical TypeScript code, with the use of the yield* keyword and the ability to employ pattern-matching.

import {Effect, pipe} from "effect";
import {InitTaskRepository} from "../../repositories/task.repository.ts";
import * as Http from "@effect/platform/HttpServer"
import {InsertTaskSchema, TaskInsert, TaskSchema} from "../../domain/task.schema.ts";
import {ResultLengthMismatch, SqlError} from "@effect/sql/Error";
import {ParseError} from "@effect/schema/ParseResult";
import {ServerResponse} from "@effect/platform/Http/ServerResponse";
import {HttpServer} from "@effect/platform";

export const insertTask = Http.router.post(
    "/tasks",
    Effect.gen(function* (_) {
            const repository = yield* InitTaskRepository
            const data = yield* Http.request.schemaBodyJson(InsertTaskSchema)
            const effect = repository.insert(data)
            const res = Effect.match(effect, {
                onFailure(e: ResultLengthMismatch | SqlError | ParseError): ServerResponse {
                    return HttpServer.response.unsafeJson(
                        {message: e},
                        {status: 404},
                    )
                }, onSuccess(value: TaskSchema): ServerResponse {
                    return HttpServer.response.unsafeJson(
                        value,
                        {status: 201},
                    )
                },
            });
            return yield* res;
        }
    )
)

GetById

The get-by ID is a very similar code I have to push the error handling higher.

import {Effect, Option} from "effect";
import {InitTaskRepository} from "../../repositories/task.repository.ts";
import * as Http from "@effect/platform/HttpServer"
import {TaskId, TaskSchema} from "../../domain/task.schema.ts";
import {ServerResponse} from "@effect/platform/Http/ServerResponse";
import {HttpServer} from "@effect/platform";

export const getTaskById = Http.router.get(
    "/tasks/:id",
    Effect.gen(function* (_) {
            const repository = yield* InitTaskRepository
            const params = yield* Http.router.params
            if (params.id === undefined) {
                return HttpServer.response.unsafeJson(
                    {message: "id not found"},
                    {status: 400})
            }
            const effect = repository.getById(params.id)
            const res = Effect.match(effect, {
                onFailure(e): ServerResponse {
                    if (e._tag === "SqlError") {
                        return HttpServer.response.unsafeJson(
                            {message: "sql error"},
                            {status: 500})
                    } else if (e._tag === "ParseError") {
                        return HttpServer.response.unsafeJson(
                            {message: "parse error"},
                            {status: 500}
                        )
                    }
                    return HttpServer.response.unsafeJson(
                        {message: e},
                        {status: 500}
                    )
                },
                onSuccess(value): ServerResponse {
                    return Option.match(value, {
                        onNone: () => HttpServer.response.unsafeJson(
                            {message: "Task not found"},
                            {status: 404},
                        ),
                        onSome: (task: TaskSchema) => HttpServer.response.unsafeJson(
                            task,
                            {status: 200},
                        )
                    })
                }
            });
            return yield* res;
        }
    )
)

I have extensively used pattern matching to manage each state of my program.

Server

The definition of the server is very simple.

import {BunHttpServer, BunRuntime} from "@effect/platform-bun";
import * as Http from "@effect/platform/HttpServer"
import {health} from "./src/api/health/health.route.ts";
import {Config, Effect, Layer} from "effect";
import {insertTask} from "./src/api/tasks/insetTask.ts";
import * as Sql from "@effect/sql-pg";
import * as Secret from "effect/Secret";
import {getTaskById} from "./src/api/tasks/getById.ts";

const ServerLive = BunHttpServer.server.layer({port: 3000})
const dependency = Layer.mergeAll(SqlLive, ServerLive)
const HttpLive = Http.router.empty.pipe(
    health,
    insertTask,
    getTaskById,
    Effect.catchTag("RouteNotFound", () => Http.response.empty({status: 404})),
    Http.server.serve(Http.middleware.logger),
    Http.server.withLogAddress,
    Layer.provide(dependency),
)
BunRuntime.runMain(Layer.launch(HttpLive))

I create empty Http.router.empty and after pipe on for adding all my endpoints:

  • health
  • insertTask
  • GetTaskByID

After I had a catcher for the RouteNotFound, it returned a 404 status error. I add 2 middleware for the logging system. To finish provide my dependency with Layer.provide(dependency). I merge all my dependency on it with this line

const dependency = Layer.mergeAll(SqlLive, ServerLive)

SqlLive is the client for the SQL database, and ServerLive is the server configuration.

const ServerLive = BunHttpServer.server.layer({port: 3000})

Conclusion

I’ve invested a solid ten hours into this project, and due to its scope, fully understanding the impact of using Effect has been challenging. However, here’s what I can share from my experience:

  • The learning curve is demanding but not insurmountable.
  • Discord has proven invaluable; it’s been more helpful than the documentation itself, and the examples in the various sub-packages have also been quite beneficial. Expect to spend time searching, similar to the days before the advent of large language models.

Is Effect suitable for everyone? I believe so, depending on the project’s nature. I am convinced that strongly typed languages enable more consistent code production, even with junior developers. A clear example is precise error handling instead of resorting to a messy try-catch approach.

Personally, I’ve enjoyed using Effect; it has sparked my interest to delve deeper and tackle more complex challenges. This is coming from someone who isn’t a big fan of TypeScript for everyday use. The functional aspect may seem daunting and disruptive, but having worked with Scala in my first job and explored Rust and a bit of Elixir in my spare time, some concepts felt familiar to me. I’m eager to witness the evolution of the Effect ecosystem and observe how the industry embraces it in the future.