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
Building production-ready typescript apps can be challenging π«£ As your app becomes bigger, speed slows down, and your pain increases π« @EffectTS_ is here to help, this is how ππ₯
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? ππΌββοΈ
Don't be scared, @EffectTS_ is just typescript ππΌββοΈ That's what makes it great π₯ It's easy to convert values and functions to Effect, sync, async, error handling, and more ππ₯
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 wrongstring
.
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 aUser
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.