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:
- More maintainable: Explicit dependencies and error handling
- More resilient: Runtime errors become compile-time errors
- More composable and testable: Effects are first-class citizens, allowing you to compose them easily
- More observable: Built-in tracing and monitoring capabilities
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:
- Dependency Inversion Principle: High-level modules should not depend on low-level modules. Both should depend on abstractions.
- Interface Segregation Principle: Clients should not be forced to depend on interfaces they do not use.
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!