Complete introduction to using Effect in Typescript

Sandro Maglione

Sandro Maglione

Web development

Learn how to get started using Effect. This article covers all the basics on how to get started with Effect:

  • What are the problems of Typescript without Effect
  • How Effect makes your application more safe
  • Understand the Effect type
  • Organize your first application using the main Effect modules
    • Data
    • Schema
    • Context
    • Layer
    • Config

Typescript without Effect

While working with a new API you stumble on this function:

const getUserById = (id: string): User => { /** */ }

So clear: pass an id, get back a valid User.

Wait, there is something wrong here πŸ€”

First, shouldn't that be a Promise? Where does this User come from?

const getUserById = (id: string): Promise<User> => { /** */ }

Okay, fixed. This is a standard and perfectly valid Typescript type signature.

If you are like most Typescript developers out there this signature will look normal and "correct".

It's not.

What can go wrong?

A lot can go wrong:

  • Missing user
  • Missing connection
  • Missing authentication
  • Invalid user

But not according to our types:

const getUserById = (id: string): Promise<User> => { /** */ }

The signature says that you pass a string, and get back a Promise<User>. Typescript is okay with that and will give you that User.

And if something goes wrong? It just crashes.

Where does the User come from?

Something else is missing here: Where does User come from?

Generally we are working with some API or database. Where is the connection opened? Is it ever closed? Where are the credentials?

Again, this is not clear.

How do we test this code?

The day comes when you are asked to test the getUserById function.

Where do you start? What options do we have?

The function only allows to pass a string. This is all we get. So we end up passing many string and somehow intercept API requests or change database parameters from somewhere to simulate various situations.

It all gets pretty complex fairly fast.


Why using Effect

If you believe this is all Typescript has to offer and that we are stuck with that, well, think again.

Every application has an inherent amount of complexity that cannot be removed or hidden.

Instead, it must be dealt with, either in product development or in user interaction.

Effect allows to manages this complexity using the full power of the Typescript type system.

Effect type

Let's come back to the previous (flawed) function:

const getUserById = (id: string): Promise<User> => { /** */ }

You first step with Effect is the Effect type.

Effect<R, E, A> describes requirements (R), errors (E), and return type (A) of a function.

Let's start by changing the return type to use Effect:

const getUserById = (id: string): Effect<never, never, User> => { /** */ }

This is the very first step: change the return type of your functions to use Effect

πŸ’‘ Notice: For now we leave requirements (R) and errors (E) as never. This means that the function has no requirements and no errors.

This is definitely incorrect, we are going to fix this next πŸ‘‡

Explicit errors in the type signature

Remember previously when we listed some possible errors?

  • Missing user
  • Missing connection
  • Missing authentication
  • Invalid user

Effect allows to make these errors explicit, directly in the type signature.

We use the E parameter:

const getUserById = (id: string): Effect<never, MissingUser | MissingConnection | MissingAuthentication | InvalidUser, User> => { /** */ }

Now it's clear exactly what can go wrong.

Other developers do not need to read the function implementation to spot all throw or possible issues with API requests or database connections.

Explicit requirements in the type signature

What about the database connection to get the user?

Also this information can be made explicit. We use the R parameter of Effect:

const getUserById = (id: string): Effect<DatabaseService, MissingUser | MissingConnection | MissingAuthentication | InvalidUser, User> => { /** */ }

Now we definitely know everything about this function. We extracted all the complexity in the type signature.

By explicitly defining all the information on a type level with can use Typescript to spot errors at compile-time.

This allows to fix most issues during development and avoid runtime crashes.

Extra: safer input parameters

We can even go a step further to make the input parameter safer.

What can go wrong here you may ask? This:

const name = "Sandro";
const id = "aUIahd1783";
 
const user = getUserById(name); // πŸ’πŸΌβ€β™‚οΈ

Since the function accepts a simple string it will happen that you somehow pass the wrong string.

The first solution is to use an object:

const getUserById = ({ id }: { id: string }): Effect<DatabaseService, MissingUser | MissingConnection | MissingAuthentication | InvalidUser, User> => { /** */ }

Now we are (relatively) safe:

const name = "Sandro";
const id = "aUIahd1783";
 
const user = getUserById({ name }); // This won't work βœ…

But now we have another issue:

const id = "Sandro";
 
const user = getUserById({ id }); // 🀦

Just because the variable is called id doesn't mean we have a valid id.

Effect comes to the rescue also here by using Brand:

type Id = string & Brand.Brand<"Id">
 
const getUserById = ({ id }: { id: Id }): Effect<DatabaseService, MissingUser | MissingConnection | MissingAuthentication | InvalidUser, User> => { /** */ }

Now, this is how Typescript should be made. The function is completely type-safe. Now it's all about the internal implementation, which is our job as developers.

There is more.

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 600+ readers.

Data: Define errors

We defined some errors in the type signature as MissingUser | MissingConnection | MissingAuthentication | InvalidUser.

Where do these come from? How are they defined?

Effect has a Data module for that:

import { Data } from "effect"
 
class MissingUser extends Data.TaggedError("MissingUser")<{
  message: string
}> {}

Data.TaggedError defines a value with a _tag of "MissingUser" and that requires message of type string as parameter:

const missingUser = new MissingUser({ message: "..." });
missingUser._tag; // πŸ‘ˆ `MissingUser`

_tag is a discriminant field: it is used to distinguishing between different types of errors during error handling.

Schema: Define and validate types

What about User instead? How is it defined?

Similar to the id parameter, not every object is a valid user.

We use @effect/schema to validate data and create a User

For example we can use Schema.Class:

class User extends Schema.Class<User>()({
  id: Schema.string.pipe(Schema.brand("Id")),
  name: Schema.string.pipe(Schema.minLength(6)),
}) {}

Context: Create services

Instead of using single global and isolated function we want to create a service.

A service refers to a reusable component or functionality that can be used by different parts of an application.

In Typescript we can use an interface to group functions:

export interface UserService {
  readonly getUserById: ({
    id,
  }: {
    id: Id;
  }) => Effect<
    DatabaseService,
    MissingUser | MissingConnection | MissingAuthentication | InvalidUser,
    User
  >;
}

Effect then provides a Context module to organize and manage services (dependency injection).

We use Context.Tag to define a service identifier for the UserService interface:

export interface UserService {
  readonly getUserById: ({
    id,
  }: {
    id: Id;
  }) => Effect<
    DatabaseService,
    MissingUser | MissingConnection | MissingAuthentication | InvalidUser,
    User
  >;
}
 
export const UserService = Context.Tag<UserService>("@app/UserService");

interface + Context.Tag is the most common way to define services in Effect.

We can define a service also for DatabaseService:

export interface DatabaseService {
  readonly initialize: Effect.Effect<never, MissingConnection, Database>;
}
 
export const DatabaseService = Context.Tag<DatabaseService>("@app/DatabaseService");

Layer: Organize and build services

We now need to create an implementation for UserService.

Effect has a Layer module that allows to create services and manage their dependencies.

First, we can remove the DatabaseService dependency from getUserById. We move this dependency instead at the full UserService using Layer:

export interface UserService {
  readonly getUserById: ({
    id,
  }: {
    id: Id;
  }) => Effect<
    never,
    MissingUser | MissingConnection | MissingAuthentication | InvalidUser,
    User
  >;
}

We then construct a Layer using Layer.effect:

/** `Layer.Layer<DatabaseService, unknown, UserService>` */
export const UserServiceLive = Layer.effect(
  UserService,
  Effect.map(DatabaseService, (db) => UserService.of({ 
    getUserById: ({ id }) => /** */
  }))
);

UserServiceLive defined a valid implementation of UserService. Inside it we need to implement the getUserById function.

Config: environmental variables

When initializing the DatabaseService we usually need to access some configuration parameters (environmental variables).

Effect provides a Config module to define and collect configuration values.

We start by defining an interface containing all the required parameters:

interface DatabaseConfig {
  readonly url: string;
  readonly password: Secret.Secret;
}

We then create a DatabaseService implementation from a make function that accepts DatabaseConfig as parameter:

const make = ({ url, password }: DatabaseConfig) => DatabaseService.of({
  initialize: /** */
});

We create a Layer using make:

export const layer = (config: Config.Config.Wrap<DatabaseConfig>) =>
  Config.unwrap(config).pipe(
    Effect.map(make),
    Layer.effect(DatabaseService)
  );

Finally, we define the final Layer implementation using the layer function:

export const DatabaseServiceLive = layer({
  url: Config.string("DATABASE_URL"), 
  password: Config.secret("DATABASE_PASSWORD"),
});

DATABASE_URL and DATABASE_PASSWORD represent our environmental variables.

Effect comes bundled with a default ConfigProvider that retrieves configuration data from environment variables.

This can be update to support more advanced configuration providers.


This are the modules you need to know to start building applications using Effect.

You start by defining all the services using Effect as return type. We can construct error values using Data. We can then create and compose instance of services using Context, Layer and Config.

We are now left with the actual implementation and execution of the API. We are going to explore this topic in a future post πŸ”œ

If you are interested to learn more, every week I publish a new open source project and share notes and lessons learned in my newsletter. You can subscribe here below πŸ‘‡

Thanks for reading.

πŸ‘‹γƒ»Interested in learning more, every week?

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 600+ readers.