Managing Services

On this page

In the context of programming, a service refers to a reusable component or functionality that can be used by different parts of an application. Services are designed to provide specific capabilities and can be shared across multiple modules or components.

Services often encapsulate common tasks or operations that are needed by different parts of an application. They can handle complex operations, interact with external systems or APIs, manage data, or perform other specialized tasks.

Services are typically designed to be modular and decoupled from the rest of the application. This allows them to be easily maintained, tested, and replaced without affecting the overall functionality of the application.

When working with services in Effect, it is important to understand the concept of context: in the type Effect<Success, Error, Requirements>, the parameter Requirements represents the services required by the effect to be executed. These services are stored in a collection called Context.

Managing Services with Effects

So far, we have been working with effects that don't require any specific service. In those cases, the Requirements parameter in the Effect<Success, Error, Requirements> type has always been of type never.

However, there are situations where we need to work with effects that depend on specific services.

These services are stored in a collection called Context.

The Context acts as a container for all the services an effect might need. By leveraging this context, you can manage requirements effectively, ensuring that each part of your application has access to the necessary services without tight coupling.

In this guide, we will learn how to:

  • Create Effects that depend on a specific context.
  • Work with Effects that require a context.
  • Provide the required context to the Effect.

Creating a Simple Service

Let's start by creating a service for generating random numbers.

To create a new service, you need two things:

  • A unique identifier.
  • A type describing the possible operations of the service.

Let's define our first service:

  • We'll use the string "MyRandomService" as the unique identifier.
  • The service type will have a single operation called next that returns a random number.
ts
import { Effect, Context } from "effect"
 
class Random extends Context.Tag("MyRandomService")<
Random,
{ readonly next: Effect.Effect<number> }
>() {}
ts
import { Effect, Context } from "effect"
 
class Random extends Context.Tag("MyRandomService")<
Random,
{ readonly next: Effect.Effect<number> }
>() {}

The exported Random value is known as a tag in Effect. It acts as a representation of the service and allows Effect to locate and use this service at runtime.

The service will be stored in a collection called Context, which can be thought of as a Map where the keys are tags and the values are services: Context = Map<Tag, Service>.

You need to specify an identifier (in this case, the string "MyRandomService") to make the tag global. This ensures that two tags with the same identifier refer to the same instance.

Using a unique identifier is particularly useful in scenarios where live reloads can occur, as it helps preserve the instance across reloads. It ensures there is no duplication of instances (although it shouldn't happen, some bundlers and frameworks can behave unpredictably)

Summary

In the Effect, understanding services, tags, and context is essential for managing requirements and building modular applications.

ConceptDescription
ServiceA reusable component providing specific functionality, used across different parts of an application.
TagA unique identifier representing a service, allowing Effect to locate and use it.
ContextA collection storing services, functioning like a map with tags as keys and services as values.

Using the Service

Now that we have our service tag defined, let's see how we can use it by building a simple program.


ts
import { Effect, Context } from "effect"
 
class Random extends Context.Tag("MyRandomService")<
Random,
{ readonly next: Effect.Effect<number> }
>() {}
 
const program = Effect.gen(function* () {
const random = yield* Random
const randomNumber = yield* random.next
console.log(`random number: ${randomNumber}`)
})
ts
import { Effect, Context } from "effect"
 
class Random extends Context.Tag("MyRandomService")<
Random,
{ readonly next: Effect.Effect<number> }
>() {}
 
const program = Effect.gen(function* () {
const random = yield* Random
const randomNumber = yield* random.next
console.log(`random number: ${randomNumber}`)
})

In the code above, we can observe that we are able to yield the Random tag as if it were an Effect itself. This allows us to access the next operation of the service.

It's worth noting that the type of the program variable includes Random in the Requirements type parameter: Effect<void, never, Random>.

This indicates that our program requires the Random service to be provided in order to execute successfully.

If we attempt to execute the effect without providing the necessary service we will encounter a type-checking error:

ts
Effect.runSync(program)
Argument of type 'Effect<void, never, Random>' is not assignable to parameter of type 'Effect<void, never, never>'. Type 'Random' is not assignable to type 'never'.2345Argument of type 'Effect<void, never, Random>' is not assignable to parameter of type 'Effect<void, never, never>'. Type 'Random' is not assignable to type 'never'.
ts
Effect.runSync(program)
Argument of type 'Effect<void, never, Random>' is not assignable to parameter of type 'Effect<void, never, never>'. Type 'Random' is not assignable to type 'never'.2345Argument of type 'Effect<void, never, Random>' is not assignable to parameter of type 'Effect<void, never, never>'. Type 'Random' is not assignable to type 'never'.

To resolve this error and successfully execute the program, we need to provide an actual implementation of the Random service.

In the next section, we will explore how to implement and provide the Random service to our program, enabling us to run it successfully.

Providing a Service implementation

In order to provide an actual implementation of the Random service, we can utilize the Effect.provideService function.

ts
const runnable = Effect.provideService(program, Random, {
next: Effect.sync(() => Math.random())
})
 
Effect.runPromise(runnable)
/*
Output:
random number: 0.8241872233134417
*/
ts
const runnable = Effect.provideService(program, Random, {
next: Effect.sync(() => Math.random())
})
 
Effect.runPromise(runnable)
/*
Output:
random number: 0.8241872233134417
*/

In the code snippet above, we call the program that we defined earlier and provide it with an implementation of the Random service. We use the Effect.provideService function to associate the Random tag with its implementation, an object with a next operation that generates a random number.

Notice that the Requirements type parameter of the runnable effect is now never. This indicates that the effect no longer requires any service to be provided. With the implementation of the Random service in place, we are able to run the program without any further requirements.

Using Multiple Services

When we require the usage of more than one service, the process remains similar to what we've learned in defining a service, repeated for each service needed. Let's examine an example where we need two services, namely Random and Logger:


ts
import { Effect, Context } from "effect"
 
// Create a tag for the 'Random' service
class Random extends Context.Tag("MyRandomService")<
Random,
{
readonly next: Effect.Effect<number>
}
>() {}
 
// Create a tag for the 'Logger' service
class Logger extends Context.Tag("MyLoggerService")<
Logger,
{
readonly log: (message: string) => Effect.Effect<void>
}
>() {}
 
const program = Effect.gen(function* () {
// Acquire instances of the 'Random' and 'Logger' services
const random = yield* Random
const logger = yield* Logger
 
// Generate a random number using the 'Random' service
const randomNumber = yield* random.next
 
// Log the random number using the 'Logger' service
return yield* logger.log(String(randomNumber))
})
ts
import { Effect, Context } from "effect"
 
// Create a tag for the 'Random' service
class Random extends Context.Tag("MyRandomService")<
Random,
{
readonly next: Effect.Effect<number>
}
>() {}
 
// Create a tag for the 'Logger' service
class Logger extends Context.Tag("MyLoggerService")<
Logger,
{
readonly log: (message: string) => Effect.Effect<void>
}
>() {}
 
const program = Effect.gen(function* () {
// Acquire instances of the 'Random' and 'Logger' services
const random = yield* Random
const logger = yield* Logger
 
// Generate a random number using the 'Random' service
const randomNumber = yield* random.next
 
// Log the random number using the 'Logger' service
return yield* logger.log(String(randomNumber))
})

The program effect now has a Requirements type parameter of Random | Logger, indicating that it requires both the Random and Logger services to be provided.

To execute the program, we need to provide implementations for both services:

ts
// Provide service implementations for 'Random' and 'Logger'
const runnable1 = program.pipe(
Effect.provideService(Random, {
next: Effect.sync(() => Math.random())
}),
Effect.provideService(Logger, {
log: (message) => Effect.sync(() => console.log(message))
})
)
ts
// Provide service implementations for 'Random' and 'Logger'
const runnable1 = program.pipe(
Effect.provideService(Random, {
next: Effect.sync(() => Math.random())
}),
Effect.provideService(Logger, {
log: (message) => Effect.sync(() => console.log(message))
})
)

Alternatively, instead of calling provideService multiple times, we can combine the service implementations into a single Context and then provide the entire context using the Effect.provide function:

ts
// Combine service implementations into a single 'Context'
const context = Context.empty().pipe(
Context.add(Random, { next: Effect.sync(() => Math.random()) }),
Context.add(Logger, {
log: (message) => Effect.sync(() => console.log(message))
})
)
 
// Provide the entire context to the 'program'
const runnable2 = Effect.provide(program, context)
ts
// Combine service implementations into a single 'Context'
const context = Context.empty().pipe(
Context.add(Random, { next: Effect.sync(() => Math.random()) }),
Context.add(Logger, {
log: (message) => Effect.sync(() => console.log(message))
})
)
 
// Provide the entire context to the 'program'
const runnable2 = Effect.provide(program, context)

By providing the necessary implementations for each service, we ensure that the runnable effect can access and utilize both services when it is executed.

Optional Services

There are situations where we may want to access a service implementation only if it is available. In such cases, we can use the Effect.serviceOption function to handle this scenario.

The Effect.serviceOption function returns an implementation that can is available only if it is actually provided before executing this effect. To represent this optionality it returns an Option of the implementation.

Let's take a look at an example that demonstrates the usage of optional services:


To determine what action to take, we can use the Option.isNone function provided by the Option module. This function allows us to check if the service is available or not by returning true when the service is not available.

ts
import { Effect, Context, Option } from "effect"
 
class Random extends Context.Tag("MyRandomService")<
Random,
{ readonly next: Effect.Effect<number> }
>() {}
 
const program = Effect.gen(function* () {
const maybeRandom = yield* Effect.serviceOption(Random)
const randomNumber = Option.isNone(maybeRandom)
? // the service is not available, return a default value
-1
: // the service is available
yield* maybeRandom.value.next
console.log(randomNumber)
})
ts
import { Effect, Context, Option } from "effect"
 
class Random extends Context.Tag("MyRandomService")<
Random,
{ readonly next: Effect.Effect<number> }
>() {}
 
const program = Effect.gen(function* () {
const maybeRandom = yield* Effect.serviceOption(Random)
const randomNumber = Option.isNone(maybeRandom)
? // the service is not available, return a default value
-1
: // the service is available
yield* maybeRandom.value.next
console.log(randomNumber)
})

In the code above, we can observe that the Requirements type parameter of the program effect is never, even though we are working with a service. This allows us to access something from the context only if it is actually provided before executing this effect.

When we run the program effect without providing the Random service:

ts
Effect.runPromise(program).then(console.log)
// Output: -1
ts
Effect.runPromise(program).then(console.log)
// Output: -1

We see that the log message contains -1, which is the default value we provided when the service was not available.

However, if we provide the Random service implementation:

ts
Effect.runPromise(
Effect.provideService(program, Random, {
next: Effect.sync(() => Math.random())
})
).then(console.log)
// Output: 0.9957979486841035
ts
Effect.runPromise(
Effect.provideService(program, Random, {
next: Effect.sync(() => Math.random())
})
).then(console.log)
// Output: 0.9957979486841035

We can observe that the log message now contains a random number generated by the next operation of the Random service.

By using the Effect.serviceOption function, we can gracefully handle scenarios where a service may or may not be available, providing flexibility in our programs.