β€’

tech

Complete introduction to using Effect in Typescript

Complete introduction to using Effect in Typescript: why using Effect, what is the Effect type, how to use it, and what are the main modules of Effect.


Sandro Maglione

Sandro Maglione

Software development

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

  • Why should I use Effect?
  • What problems does Effect solve?
  • 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): Promise<User> => { /** */ }

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

Wait, there is something wrong here πŸ€”

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<A, E, R> describes return type (A), errors (E), and requirements (R) of a function.

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

const getUserById = (id: string): Promise<User> => { /** */ } // [!code --]
const getUserById = (id: string): Effect<User> => { /** */ } // [!code ++]

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

It doesn't look much different from normal typescript, right? πŸ’πŸΌβ€β™‚οΈ

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<
  User,
  MissingUser | MissingConnection | MissingAuthentication | InvalidUser
> => { /** */ }

Now it's clear exactly what can go wrong.

πŸ€” Why: 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<
  User,
  MissingUser | MissingConnection | MissingAuthentication | InvalidUser,
  DatabaseService
> => { /** */ }

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 getUserById = (id: string): Effect<
  User,
  MissingUser | MissingConnection | MissingAuthentication | InvalidUser,
  DatabaseService
> => { /** */ }

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<
  User,
  MissingUser | MissingConnection | MissingAuthentication | InvalidUser,
  DatabaseService
> => { /** */ }

Now we are (relatively) safe:

const name = "Sandro";
const id = "aUIahd1783";

const user = getUserById({ name }); // πŸ‘ˆ Error: 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<
  User,
  MissingUser | MissingConnection | MissingAuthentication | InvalidUser,
  DatabaseService
> => { /** */ }

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 🀩

Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.

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>("User")({
  id: Schema.String.pipe(Schema.brand("Id")),
  name: Schema.String.pipe(Schema.minLength(6)),
}) {}

Context: Create services

Instead of using 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<
    User,
    MissingUser | MissingConnection | MissingAuthentication | InvalidUser,
    DatabaseService
  >;
}

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

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

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

export const UserService = Context.GenericTag<UserService>("@app/UserService");

We can define a service also for DatabaseService:

export interface DatabaseService {
  readonly initialize: Effect.Effect<Database, MissingConnection>;
}

export const DatabaseService = Context.GenericTag<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<
    User,
    MissingUser | MissingConnection | MissingAuthentication | InvalidUser,
    DatabaseService // [!code --]
  >;
}

We then construct a Layer using Layer.effect:

/** `Layer.Layer<UserService, never, DatabaseService>` */
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?

Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.