Introduction to Runtime

On this page

The Runtime<R> data type represents a Runtime System that can execute effects. To execute any effect, we need a Runtime that includes the necessary requirements for that effect.

A Runtime<R> consists of three main components:

  • a value of type Context<R>
  • a value of type FiberRefs
  • a value of type RuntimeFlags

The Default Runtime

When we use functions like Effect.run*, we are actually using the default runtime without explicitly mentioning it. These functions are designed as convenient shortcuts for executing our effects using the default runtime.

For instance, in the Runtime module, there is a corresponding Runtime.run*(defaultRuntime) function that is called internally by Effect.run*, e.g. Effect.runSync is simply an alias for Runtime.runSync(defaultRuntime).

The default runtime includes:

  • An empty Context<never>
  • A set of FiberRefs that include the default services
  • A default configuration for RuntimeFlags that enables Interruption and CooperativeYielding

In most cases, using the default runtime is sufficient. However, it can be useful to create a custom runtime to reuse a specific context or configuration. It is common to create a Runtime<R> by initializing a Layer<R, Err, RIn>. This allows for context reuse across execution boundaries, such as in a React App or when executing operations on a server in response to API requests.

What is a Runtime System?

When we write an Effect program, we construct an Effect using constructors and combinators. Essentially, we are creating a blueprint of a program. An Effect is merely a data structure that describes the execution of a concurrent program. It represents a tree-like structure that combines various primitives to define what the Effect should do.

However, this data structure itself does not perform any actions; it is solely a description of a concurrent program.

Therefore, it is crucial to understand that when working with a functional effect system like Effect, our code for actions such as printing to the console, reading files, or querying databases is actually building a workflow or blueprint for an application. We are constructing a data structure.

So how does Effect actually run these workflows? This is where the Effect Runtime System comes into play. When we invoke a Runtime.run* function, the Runtime System takes over. First, it creates an empty root Fiber with:

  • The initial context
  • The initial fiberRefs
  • The initial Effect

After the creation of the Fiber, it invokes the Fiber's runLoop, which follows the instructions described by the Effect and executes them step by step.

To simplify, we can envision the Runtime System as a black box that takes both the effect (Effect<A, E, R>) and its associated context (Context<R>). It runs the effect and returns the result as an Exit<A, E> value.

Runtime

Responsibilities of the Runtime System

Runtime Systems have a lot of responsibilities:

  1. Execute every step of the blueprint. They have to execute every step of the blueprint in a while loop until it's done.

  2. Handle unexpected errors. They have to handle unexpected errors, not just the expected ones but also the unexpected ones.

  3. Spawn concurrent fiber. They are actually responsible for the concurrency that effect systems have. They have to spawn a fiber every time we call fork on an effect to spawn off a new fiber.

  4. Cooperatively yield to other fibers. They have to cooperatively yield to other fibers so that fibers that are sort of hogging the spotlight, don't get to monopolize all the CPU resources.

  5. Ensure finalizers are run appropriately. They have to ensure finalizers are run appropriately at the right point in all circumstances to make sure that resources are closed that clean-up logic is executed. This is the feature that powers Scope and all the other resource-safe constructs in Effect.

  6. Handle asynchronous callback. They have to handle this messy job of dealing with asynchronous callbacks. So we don't have to deal with async code. When we are using Effect, everything can be interpreted as async or sync out of the box.

Default Runtime

Effect provides a default runtime named Runtime.defaultRuntime designed for mainstream usage.

The default runtime provides the minimum capabilities to bootstrap execution of Effect tasks.

Both of the following executions are equivalent:

ts
import { Effect, Runtime } from "effect"
 
const program = Effect.log("Application started!")
 
Effect.runSync(program)
/*
Output:
... level=INFO fiber=#0 message="Application started!"
*/
 
Runtime.runSync(Runtime.defaultRuntime)(program)
/*
Output:
... level=INFO fiber=#0 message="Application started!"
*/
ts
import { Effect, Runtime } from "effect"
 
const program = Effect.log("Application started!")
 
Effect.runSync(program)
/*
Output:
... level=INFO fiber=#0 message="Application started!"
*/
 
Runtime.runSync(Runtime.defaultRuntime)(program)
/*
Output:
... level=INFO fiber=#0 message="Application started!"
*/

Under the hood, Effect.runSync (and the same principle applies to other Effect.run* functions) serves as a convenient shorthand for Runtime.runSync(Runtime.defaultRuntime).

Locally Scoped Runtime Configuration

In Effect, runtime configurations are typically inherited from their parent workflows. This means that when we access a runtime configuration or obtain a runtime inside a workflow, we are essentially using the configuration of the parent workflow. However, there are cases where we want to temporarily override the runtime configuration for a specific part of our code. This concept is known as locally scoped runtime configuration. Once the execution of that code region is completed, the runtime configuration reverts to its original settings.

To achieve this, we make use of Effect.provide* functions, which allow us to provide a new runtime configuration to a specific section of our code.

Configuring Runtime by Providing Configuration Layers

By utilizing the Effect.provide function and providing runtime configuration layers to an Effect workflow, we can easily modify runtime configurations.

Here's an example:

ts
import { Logger, Effect } from "effect"
 
// Define a configuration layer
const addSimpleLogger = Logger.replace(
Logger.defaultLogger,
Logger.make(({ message }) => console.log(message))
)
 
const program = Effect.gen(function* () {
yield* Effect.log("Application started!")
yield* Effect.log("Application is about to exit!")
})
 
Effect.runSync(program)
/*
Output:
timestamp=... level=INFO fiber=#0 message="Application started!"
timestamp=... level=INFO fiber=#0 message="Application is about to exit!"
*/
 
// Overriding the default logger
Effect.runSync(program.pipe(Effect.provide(addSimpleLogger)))
/*
Output:
Application started!
Application is about to exit!
*/
ts
import { Logger, Effect } from "effect"
 
// Define a configuration layer
const addSimpleLogger = Logger.replace(
Logger.defaultLogger,
Logger.make(({ message }) => console.log(message))
)
 
const program = Effect.gen(function* () {
yield* Effect.log("Application started!")
yield* Effect.log("Application is about to exit!")
})
 
Effect.runSync(program)
/*
Output:
timestamp=... level=INFO fiber=#0 message="Application started!"
timestamp=... level=INFO fiber=#0 message="Application is about to exit!"
*/
 
// Overriding the default logger
Effect.runSync(program.pipe(Effect.provide(addSimpleLogger)))
/*
Output:
Application started!
Application is about to exit!
*/

In this example, we first create a configuration layer for a simple logger using Logger.replace. Then, we use Effect.provide to provide this configuration to our program, effectively overriding the default logger with the simple logger.

To ensure that the runtime configuration is only applied to a specific part of an Effect application, we should provide the configuration layer exclusively to that particular section, as demonstrated in the following example:

ts
import { Logger, Effect } from "effect"
 
// Define a configuration layer
const addSimpleLogger = Logger.replace(
Logger.defaultLogger,
Logger.make(({ message }) => console.log(message))
)
 
const program = Effect.gen(function* () {
yield* Effect.log("Application started!")
yield* Effect.gen(function* () {
yield* Effect.log("I'm not going to be logged!")
yield* Effect.log("I will be logged by the simple logger.").pipe(Effect.provide(addSimpleLogger))
yield* Effect.log("Reset back to the previous configuration, so I won't be logged.")
}).pipe(Effect.provide(Logger.remove(Logger.defaultLogger)))
yield* Effect.log("Application is about to exit!")
})
 
Effect.runSync(program)
/*
Output:
timestamp=... level=INFO fiber=#0 message="Application started!"
I will be logged by the simple logger.
timestamp=... level=INFO fiber=#0 message="Application is about to exit!"
*/
ts
import { Logger, Effect } from "effect"
 
// Define a configuration layer
const addSimpleLogger = Logger.replace(
Logger.defaultLogger,
Logger.make(({ message }) => console.log(message))
)
 
const program = Effect.gen(function* () {
yield* Effect.log("Application started!")
yield* Effect.gen(function* () {
yield* Effect.log("I'm not going to be logged!")
yield* Effect.log("I will be logged by the simple logger.").pipe(Effect.provide(addSimpleLogger))
yield* Effect.log("Reset back to the previous configuration, so I won't be logged.")
}).pipe(Effect.provide(Logger.remove(Logger.defaultLogger)))
yield* Effect.log("Application is about to exit!")
})
 
Effect.runSync(program)
/*
Output:
timestamp=... level=INFO fiber=#0 message="Application started!"
I will be logged by the simple logger.
timestamp=... level=INFO fiber=#0 message="Application is about to exit!"
*/

Top-level Runtime Configuration

When developing an Effect application and executing it using Effect.run* functions, the application is automatically run using the default runtime behind the scenes. While we can adjust and customize specific aspects of our Effect application by providing locally scoped configuration layers using Effect.provide operations, there are scenarios where we need to customize the runtime configuration for the entire application from the top level.

In such situations, we can create a top-level runtime by converting a configuration layer into a runtime using the Layer.toRuntime operator:

ts
import { Logger, Scope, Layer, Effect, Runtime, Exit } from "effect"
 
// Define a configuration layer
const appLayer = Logger.replace(
Logger.defaultLogger,
Logger.make(({ message }) => console.log(message))
)
 
// Create a scope for resources used in the configuration layer
const scope = Effect.runSync(Scope.make())
 
// Transform the configuration layer into a runtime
const runtime = await Effect.runPromise(
Layer.toRuntime(appLayer).pipe(Scope.extend(scope))
)
 
// Define a custom running function
const runSync = Runtime.runSync(runtime)
 
const program = Effect.log("Application started!")
 
// Execute the program using the custom runtime
runSync(program)
/*
Output:
Application started!
*/
 
// Cleaning up any resources used by the configuration layer
Effect.runFork(Scope.close(scope, Exit.void))
ts
import { Logger, Scope, Layer, Effect, Runtime, Exit } from "effect"
 
// Define a configuration layer
const appLayer = Logger.replace(
Logger.defaultLogger,
Logger.make(({ message }) => console.log(message))
)
 
// Create a scope for resources used in the configuration layer
const scope = Effect.runSync(Scope.make())
 
// Transform the configuration layer into a runtime
const runtime = await Effect.runPromise(
Layer.toRuntime(appLayer).pipe(Scope.extend(scope))
)
 
// Define a custom running function
const runSync = Runtime.runSync(runtime)
 
const program = Effect.log("Application started!")
 
// Execute the program using the custom runtime
runSync(program)
/*
Output:
Application started!
*/
 
// Cleaning up any resources used by the configuration layer
Effect.runFork(Scope.close(scope, Exit.void))

In this example, we first create a custom configuration layer called appLayer, which includes modifications to the logger configuration. Next, we transform this configuration layer into a runtime using Layer.toRuntime. This results in a top-level runtime that encapsulates the desired configuration for the entire Effect application.

We then define a custom runtime function named myrunSync using Runtime.runSync(runtime). Finally, we create our Effect program and execute it using the custom runtime, which applies the top-level configuration.

By customizing the top-level runtime configuration, we can tailor the behavior of our entire Effect application to meet our specific needs and requirements.

Providing Context to the Runtime System

Custom runtimes can simplify the execution of multiple effects that require the same environment. This eliminates the need to use Effect.provide* on each individual effect before running them.

Let's break down this concept with an example. Imagine we want to create a Runtime tailored for testing purposes, where services don't interact with real external APIs. In such cases, we can design a custom runtime specifically for testing.

Suppose we have defined two services: LoggingService and EmailService:

ts
import { Effect, Context } from "effect"
 
class LoggingService extends Context.Tag("LoggingService")<
LoggingService,
{ log: (line: string) => Effect.Effect<void> }
>() {}
 
class EmailService extends Context.Tag("EmailService")<
EmailService,
{ send: (user: string, content: string) => Effect.Effect<void> }
>() {}
ts
import { Effect, Context } from "effect"
 
class LoggingService extends Context.Tag("LoggingService")<
LoggingService,
{ log: (line: string) => Effect.Effect<void> }
>() {}
 
class EmailService extends Context.Tag("EmailService")<
EmailService,
{ send: (user: string, content: string) => Effect.Effect<void> }
>() {}

We plan to implement a live version of LoggingService and a fake version of EmailService for testing:

ts
import { Console } from "effect"
 
const LoggingServiceLive = LoggingService.of({
log: Console.log
})
 
const EmailServiceFake = EmailService.of({
send: (user, content) => Console.log(`sending email to ${user}: ${content}`)
})
ts
import { Console } from "effect"
 
const LoggingServiceLive = LoggingService.of({
log: Console.log
})
 
const EmailServiceFake = EmailService.of({
send: (user, content) => Console.log(`sending email to ${user}: ${content}`)
})

Let's create a custom runtime that includes these two service implementations within its context:

ts
import { Runtime, FiberRefs } from "effect"
 
const testableRuntime = Runtime.make({
context: Context.make(LoggingService, LoggingServiceLive).pipe(
Context.add(EmailService, EmailServiceFake)
),
fiberRefs: FiberRefs.empty(),
runtimeFlags: Runtime.defaultRuntimeFlags
})
ts
import { Runtime, FiberRefs } from "effect"
 
const testableRuntime = Runtime.make({
context: Context.make(LoggingService, LoggingServiceLive).pipe(
Context.add(EmailService, EmailServiceFake)
),
fiberRefs: FiberRefs.empty(),
runtimeFlags: Runtime.defaultRuntimeFlags
})

Now we can execute our effects using this custom Runtime<LoggingService | EmailService>:

ts
const program = Effect.gen(function* () {
const { log } = yield* LoggingService
const { send } = yield* EmailService
yield* log("sending newsletter")
yield* send("David", "Hi! Here is today's newsletter.")
})
 
Runtime.runPromise(testableRuntime)(program)
/*
Output:
sending newsletter
sending email to David: Hi! Here is today's newsletter.
*/
ts
const program = Effect.gen(function* () {
const { log } = yield* LoggingService
const { send } = yield* EmailService
yield* log("sending newsletter")
yield* send("David", "Hi! Here is today's newsletter.")
})
 
Runtime.runPromise(testableRuntime)(program)
/*
Output:
sending newsletter
sending email to David: Hi! Here is today's newsletter.
*/

By providing context to the runtime system, we create a specialized environment for our effects, making it easier to test and control their behavior as needed.