Skip to content

Why We Chose Effect for Building Spiko

Published:
Samuel Briole

Introduction

When starting to develop Spiko, I chose TypeScript for its ability to provide a consistent language across our entire stack. Having worked extensively with TypeScript before, I was already familiar with both its strengths and limitations.

In previous projects, I had successfully used fp-ts to address some of TypeScript’s shortcomings through functional programming patterns. By 2023, when we began developing Spiko, Effect had emerged as the natural evolution of fp-ts, offering a more comprehensive, refined, and documented approach.

Effect has now become a fundamental part of our tech stack, and in this article, I want to highlight a few key benefits we’ve experienced with it.


What is Effect?

Effect is what its creators describe as “production-ready TypeScript” - a powerful library that enhances TypeScript’s capabilities.

Built on functional programming principles, Effect doesn’t require you to be an FP expert to benefit from its features. It provides a structured approach that makes your code:

Beyond the core library, Effect offer a rich ecosystem of tools and libraries to help you build robust applications.


Why We Chose Effect

1. Type-Safe Error Handling

Traditional error handling in TypeScript lacks compile-time type safety because a Promise can reject with any type of error. For instance, consider the following code:

const fetchUser = (id: string): Promise<User> =>
  fetch(`/api/users/${id}`).then(r => r.json());

This code can throw any error, and the caller has no way to know what to expect. This leads to potential runtime errors that are hard to track down.

Effect provides a solution to this problem by allowing you to define the possible errors that can occur in your code. For example, you can use Effect to handle errors in a type-safe manner:

import { Effect } from "effect";

// Using Effect for type-safe error handling
const fetchUser = (id: string): Effect.Effect<User, FetchError> =>
  Effect.tryPromise({
    try: () => fetch(`/api/users/${id}`).then(r => r.json()),
    catch: error => new FetchError(`Failed to fetch user: ${error}`),
  });

With this approach, the caller of fetchUser knows exactly what errors can occur within the type signature. It allows to anticipate and handle errors at compile time, reducing the risk of runtime errors.


2. Elegant Dependency Management

One of the important aspects of building a complex application is managing dependencies. Effect provides a way to manage dependencies in a type-safe manner using the Context and Layer abstractions. You start by defining a service interface:

import { Context, Effect } from "effect";

// Define a service interface
class UserRepository extends Context.Tag("UserRepository")<
  UserRepository,
  { findById: (id: string) => Effect.Effect<User, RepositoryError> }
>() {}

Then, you can create a Layer that provides an implementation of this service:

import { Layer } from "effect";

const UserRepositoryLive = Layer.succeed(UserRepository, {
  findById: id =>
    Effect.succeed({ id, name: "John Doe", email: "[email protected]" }),
});

You can now create a program that requires the UserRepository service:

const program: Effect.Effect<User, RepositoryError, UserRepository> =
  Effect.gen(function* () {
    // Access the UserRepository context
    const repo = yield* UserRepository;
    const user = yield* repo.findById("123");
    yield* Effect.log(user);
  });

Finally, you can run the program by providing the implementation layer to the program:

program.pipe(Effect.provide(UserRepositoryLive), Effect.runPromise);

This architecture naturally aligns with two fundamental SOLID principles:


3. Robust Ecosystem for Fullstack Development

Effect provides a rich ecosystem of libraries that make it easy to build fullstack applications. For instance, if you want to build a backend application, you will usually need to handle HTTP requests and interact with a database.

@effect/platform provides tools for building HTTP servers and clients, with built-in support for routing, middleware, and request validation. You can think of it as a type-safe alternative to Express or Fastify. Let’s see how we can create a simple HTTP API using the HttpApi module.

You start by defining your API types with the Schema module:

import {
  HttpApi,
  HttpApiGroup,
  HttpApiEndpoint,
  HttpApiSchema,
} from "@effect/platform";
import { Schema } from "effect";

const User = Schema.Struct({
  id: Schema.Number,
  name: Schema.String,
  createdAt: Schema.DateTimeUtc,
});

const idParam = HttpApiSchema.param("id", Schema.NumberFromString);

const usersGroup = HttpApiGroup.make("users").add(
  HttpApiEndpoint.get("getUser")`/user/${idParam}`.addSuccess(User)
);

const api = HttpApi.make("myApi").add(usersGroup);

Next, you can create an implementation of this API spec that will be served over HTTP from a NodeJS server (other runtimes like bun are also supported):

import { HttpApiBuilder } from "@effect/platform";
import { Effect } from "effect";

const usersGroupLive = HttpApiBuilder.group(api, "users", handlers =>
  handlers.handle("getUser", ({ path: { id } }) =>
    // In a real application, you would fetch the user from a database
    Effect.succeed({
      id,
      name: "John Doe",
      createdAt: DateTime.unsafeNow(),
    })
  )
);

const MyApiLive = HttpApiBuilder.api(api).pipe(Layer.provide(usersGroupLive));

And you can auto-generate a client from this API definition using the HttpApiClient module:

const program = Effect.gen(function* () {
  const client = yield* HttpApiClient.make(MyApi, {
    baseUrl: "http://localhost:3000",
  });
  const user = yield* client.users.getUser({ id: 123 });
});

This client is type-safe and will automatically validate the request and response types based on the API definition. This is especially useful for ensuring that your frontend and backend are in sync.

Now let’s say you want to interact with a PostgreSQL database. @effect/sql and @effect/sql-pg provide a way to interact with PostgreSQL databases. Let’s see how we can create a simple query.

First, we define our database schema using the Schema module. This will allow us to perform validation on the data we receive from the database:

import { Schema } from "effect";

class User extends Schema.Class<User>("User")({
  id: Schema.Number,
  name: Schema.String,
  createdAt: Schema.DateFromSelf,
}) {}

We can now create a program that will query the database for a user by ID, and that takes an SqlClient as a dependency:

import { SqlClient, SqlResolver } from "@effect/sql";
import { Effect } from "effect";

const program: Effect.Effect<void, ParseError | SqlError, SqlClient> =
  Effect.gen(function* () {
    const sql = yield* SqlClient.SqlClient;

    const GetUserById = yield* SqlResolver.findById("GetUserById", {
      Id: Schema.Number,
      Result: User,
      ResultId: result => result.id,
      execute: ids => sql`SELECT * FROM user WHERE id IN ${sql.in(ids)}`,
    });

    const user = yield* GetUserById.execute(123);
    yield* Effect.log(user);
  });

4. Enhanced Observability with OpenTelemetry

Effect has a built-in Tracing and Metrics module that can export in the OpenTelemetry format using the @effect/opentelemetry package. This is a game-changer for observability in your applications. The only thing you need is to setup a Layer that will provide opentelemetry instrumentation for your application:

import { NodeSdk } from "@effect/opentelemetry";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-grpc";
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";

const OtelLayer = NodeSdk.layer(() => ({
  resource: {
    serviceName: "my-api",
    serviceVersion: "1.0.0",
    spanProcessor: new BatchSpanProcessor(
      new OTLPTraceExporter({ url: process.env.OTEL_EXPORTER_ENDPOINT })
    ),
  },
}));

Then you can use the Effect.withSpan method to create spans around your effects. This allows you to trace the execution of your code and see how long each operation takes.

const processUserRequest = (userId: string, requestData: RequestData) =>
  Effect.gen(function* () {
    const userData = yield* fetchUserData(userId);
    return yield* executeUserOperation(userData, requestData);
  }).pipe(Effect.withSpan("processUserRequest", { attributes: { userId } }));

const program = processUserRequest("user-123", { type: "update-profile" });

// Provide the OpenTelemetry layer to the program
program.pipe(Effect.provide(program, OtelLayer), Effect.runPromise);

Last but not least, if you are using both @effect/platform and the opentelemetry exporter, HTTP requests and responses are automatically linked between the client and the server. This is very powerful to achieve distributed tracing when you have a microservices architecture.


Conclusion

Effect has shaped how we build software at Spiko. What began as a natural evolution from fp-ts has become the backbone of our architecture. Our codebase is resilient, easy to maintain, and our team works with great confidence when extending or refactoring features.

The real-world impact is tangible: few production bugs, simple testing, and clear code organization. For teams building complex TypeScript applications, especially those struggling with error handling or asynchronous workflows, Effect offers a compelling solution.

We’re just scratching the surface in this article. In future posts, we’ll share more about our experiences with Effect, including advanced patterns, performance optimizations, and real-world use cases. Stay tuned!


Next Post
Benchmarking TypeScript Type Checking Performance