Managing Services
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<R, E, A>
, the parameter R
represents the contextual data required by the effect to be executed. This contextual data is stored in a collection called Context
.
Managing Services with Effects
So far, we have been working with effects that don't require any specific context. In those cases, the R
parameter in the Effect<R, E, A>
type has always been of type never
.
However, there are situations where we need to work with effects that depend on specific services or contextual data.
In this guide, we will learn how to:
- Create Effects that depend on a specific context.
- Work with Effects that require a context or service dependencies.
- Provide the required context to the Effect.
Understanding how to manage services and provide the necessary context to effects is essential for building more complex and customizable programs. Let's dive in and explore these concepts in detail.
Creating a Simple Service
Let's start by creating a service for a random number generator.
import { Effect, Context } from "effect"
export interface Random {
readonly next: Effect.Effect<never, never, number>
}
export const Random = Context.Tag<Random>()
The code above defines an interface called Random
, which represents our service.
It has a single operation called next
that returns a random number.
The Random
value is what is referred to as a "Tag" in Effect.
It serves as a representation of the Random
service and allows Effect to locate and use this service at runtime.
Conceptually, we can start building a mental model of the context of an effect by thinking of it as a Map
:
Map<Tag, ServiceImpl>
Where the Tag acts as the "key" to the service implementation within the context.
Naming the interface the same as the value makes it easier to import in TypeScript, so it's recommended that you name the interface the same as the related Tag.
If desired, you can specify a unique key
as shown below:
export const Random = Context.Tag<Random>("myapp/Random")
When you specify the key
, it makes the Tag
global, which means that two tags with the same key
will refer to the same instance:
import { Context } from "effect"
console.log(Context.Tag() === Context.Tag()) // Output: false
console.log(Context.Tag("PORT") === Context.Tag("PORT")) // Output: true
This feature comes in handy in scenarios where live reloads can occur, and you want to preserve the instance across reloads. It ensures that there is no duplication of instances (although it should not happen in the first place, some bundlers and frameworks can behave strangely, and we don't have control over them). Additionally, using a unique key
allows for better printing of a tag. If you ever face "Service Not Found" or "Service Not Found: myapp/Random," the second is better and provides more helpful information to identify the issue.
Using the Service
Now that we have our service interface defined, let's see how we can use it by building a pipeline.
import { Effect } from "effect"
import { Random } from "./service"
// $ExpectType Effect<Random, never, void>
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.
We then use the Console.log
utility to log the generated random number.
It's worth noting that the type of the program
variable includes Random
in the R
type parameter:
Effect<Random, never, void>
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:
Effect.runSync(program)
We will encounter a type-checking error similar to the following:
Argument of type 'Effect<Random, never, void>' is not assignable to parameter of type 'Effect<never, never, void>'.
Type 'Random' is not assignable to type 'never'.ts(2345)
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.
// $ExpectType Effect<never, never, void>
const runnable = Effect.provideService(
program,
Random,
Random.of({
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(effect, tag, implementation)
function to associate the Random
service with its implementation, an object with a next
operation that generates a random number passed to Random.of()
(a convenience function to construct and return the service with the correct type).
Notice that the R
type parameter of the runnable
effect is now never
. This indicates that the effect no longer requires any context to be provided. With the implementation of the Random
service in place, we are able to run the program without any further dependencies.
By calling Effect.runPromise(runnable)
, the program is executed, and the resulting output can be observed on the console.
Using the Service multiple times
This section is relevant only if you are using the pipe
syntax. If you are
not using pipe
, you can skip this section.
In the program
definition, we have used the Random
service only once:
import { Effect, Console } from "effect"
import { Random } from "./service"
// $ExpectType Effect<Random, never, void>
const program = Random.pipe(
Effect.flatMap((random) => random.next),
Effect.flatMap((randomNumber) =>
Console.log(`random number: ${randomNumber}`)
)
)
But what if we need to use it multiple times? In such cases, we need to ensure that the service handle (random
) remains in scope.
import { Effect, Console } from "effect"
import { Random } from "./service"
// $ExpectType Effect<Random, never, void>
const program = Random.pipe(
Effect.flatMap((random) => random.next),
Effect.flatMap((randomNumber) =>
Console.log(`random number: ${randomNumber}`)
),
Effect.flatMap(() => Console.log(`another random number: ???`)) // <= I can't access the random service here
)
The solution depends on whether we are using Effect.gen
or pipe
:
With Effect.gen
our service handle is always in scope:
import { Effect } from "effect"
import { Random } from "./service"
// $ExpectType Effect<Random, never, void>
const program = Effect.gen(function* (_) {
const random = yield* _(Random)
const randomNumber = yield* _(random.next)
console.log(`random number: ${randomNumber}`)
const anotherRandomNumber = yield* _(random.next)
console.log(`another random number: ${anotherRandomNumber}`)
})
Using Multiple Services
When we need to manage multiple services in our program, we can utilize the Effect.all(tags)
function.
By passing a tuple of tags, we can access the corresponding tuple of services:
import { Effect, Context } from "effect"
interface Random {
readonly next: Effect.Effect<never, never, number>
}
const Random = Context.Tag<Random>()
interface Logger {
readonly log: (message: string) => Effect.Effect<never, never, void>
}
const Logger = Context.Tag<Logger>()
// $ExpectType Effect<Random | Logger, never, void>
const program = Effect.gen(function* (_) {
const random = yield* _(Random)
const logger = yield* _(Logger)
const randomNumber = yield* _(random.next)
return yield* _(logger.log(String(randomNumber)))
})
In the code above, we define two service interfaces: Random
and Logger
. Each interface represents a different service with its own set of operations.
The program
effect now has an R
type parameter of Random | Logger
, indicating that it requires both the Random
and Logger
services to be provided.
Effect<Random | Logger, never, void>
To execute the program
, we need to provide implementations for both services:
const runnable1 = program.pipe(
Effect.provideService(
Random,
Random.of({
next: Effect.sync(() => Math.random())
})
),
Effect.provideService(
Logger,
Logger.of({
log: Console.log
})
)
)
Alternatively, instead of calling provideService
multiple times, we can combine the service implementations into a single Context
:
// Context<Random | Logger>
const context = Context.empty().pipe(
Context.add(Random, Random.of({ next: Effect.sync(() => Math.random()) })),
Context.add(
Logger,
Logger.of({
log: Console.log
})
)
)
and then provide the entire context using the Effect.provide
function:
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(tag)
function to handle this scenario.
The 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:
Effect<never, never, Option<Random>>
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.
import { Effect, Option } from "effect"
import { Random } from "./service"
// $ExpectType Effect<never, never, void>
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 R
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:
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:
Effect.runPromise(
Effect.provideService(
program,
Random,
Random.of({
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.
In the upcoming section, we will delve into the topic of Layers. Layers are powerful constructs for creating services and composing them together, enabling us to build complex dependency graphs that can be seamlessly provided to Effects.