Configuration
On this page
Configuration is an essential aspect of any cloud-native application. Effect simplifies the process of managing configuration by offering a convenient interface for configuration providers.
The configuration front-end in Effect enables ecosystem libraries and applications to specify their configuration requirements in a declarative manner. It offloads the complex tasks to a ConfigProvider
, which can be supplied by third-party libraries.
Effect comes bundled with a straightforward default ConfigProvider
that retrieves configuration data from environment variables. This default provider can be used during development or as a starting point before transitioning to more advanced configuration providers.
To make our application configurable, we need to understand three essential elements:
-
Config Description: We describe the configuration data using an instance of
Config<A>
. If the configuration data is simple, such as astring
,number
, orboolean
, we can use the built-in functions provided by theConfig
module. For more complex data types likeHostPort
, we can combine primitive configs to create a custom configuration description. -
Config Frontend: We utilize the instance of
Config<A>
to load the configuration data described by the instance (aConfig
is, in itself, an effect). This process leverages the currentConfigProvider
to retrieve the configuration. -
Config Backend: The
ConfigProvider
serves as the underlying engine that manages the configuration loading process. Effect comes with a default config provider as part of its default services. This default provider reads the configuration data from environment variables. If we want to use a custom config provider, we can utilize theLayer.setConfigProvider
layer to configure the Effect runtime accordingly.
Getting Started
Effect provides a set of primitives for the most common types like string
, number
, boolean
, integer
, etc.
Let's start with a simple example of how to read configuration from environment variables:
ts
import {Effect ,Config } from "effect"constprogram =Effect .gen (function* (_ ) {consthost = yield*_ (Config .string ("HOST"))constport = yield*_ (Config .number ("PORT"))console .log (`Application started: ${host }:${port }`)})Effect .runSync (program )
ts
import {Effect ,Config } from "effect"constprogram =Effect .gen (function* (_ ) {consthost = yield*_ (Config .string ("HOST"))constport = yield*_ (Config .number ("PORT"))console .log (`Application started: ${host }:${port }`)})Effect .runSync (program )
If we run this application we will get the following output:
bash
(Missing data at HOST: "Expected HOST to exist in the process context")
bash
(Missing data at HOST: "Expected HOST to exist in the process context")
This is because we have not provided any configuration. Let's try running it with the following environment variables:
bash
HOST=localhost PORT=8080 ts-node primitives.ts
bash
HOST=localhost PORT=8080 ts-node primitives.ts
Now we get the following output:
bash
Application started: localhost:8080
bash
Application started: localhost:8080
Primitives
Effect offers these basic types out of the box:
string
: Constructs a config for a string value.number
: Constructs a config for a float value.boolean
: Constructs a config for a boolean value.integer
: Constructs a config for a integer value.date
: Constructs a config for a date value.literal
: Constructs a config for a literal (*) value.logLevel
: Constructs a config for a LogLevel value.duration
: Constructs a config for a duration value.secret
: Constructs a config for a secret value.
(*) string | number | boolean | null | bigint
Default Values
In some cases, you may encounter situations where an environment variable is not set, leading to a missing value in the configuration. To handle such scenarios, Effect provides the Config.withDefault
function. This function allows you to specify a fallback or default value to use when an environment variable is not present.
Here's how you can use Config.withDefault
to handle fallback values:
ts
import {Effect ,Config } from "effect"constprogram =Effect .gen (function* (_ ) {consthost = yield*_ (Config .string ("HOST"))constport = yield*_ (Config .number ("PORT").pipe (Config .withDefault (8080)))console .log (`Application started: ${host }:${port }`)})Effect .runSync (program )
ts
import {Effect ,Config } from "effect"constprogram =Effect .gen (function* (_ ) {consthost = yield*_ (Config .string ("HOST"))constport = yield*_ (Config .number ("PORT").pipe (Config .withDefault (8080)))console .log (`Application started: ${host }:${port }`)})Effect .runSync (program )
When running the program with the command:
bash
HOST=localhost ts-node withDefault.ts
bash
HOST=localhost ts-node withDefault.ts
you will see the following output:
bash
Application started: localhost:8080
bash
Application started: localhost:8080
Even though the PORT
environment variable is not set, the fallback value of 8080
is used, ensuring that the program continues to run smoothly with a default value.
Constructors
Effect provides several built-in constructors. These are functions that take a Config
as input and produce another Config
.
array
: Constructs a config for an array of values.chunk
: Constructs a config for a sequence of values.option
: Returns an optional version of this config, which will beNone
if the data is missing from configuration, andSome
otherwise.repeat
: Returns a config that describes a sequence of values, each of which has the structure of this config.hashSet
: Constructs a config for a sequence of values.hashMap
: Constructs a config for a sequence of values.
In addition to the basic ones, there are three special constructors you might find useful:
succeed
: Constructs a config which contains the specified value.fail
: Constructs a config that fails with the specified message.all
: Constructs a config from a tuple / struct / arguments of configs.
Example
ts
import {Effect ,Config } from "effect"constprogram =Effect .gen (function* (_ ) {constconfig = yield*_ (Config .array (Config .string (), "MY_ARRAY"))console .log (config )})Effect .runSync (program )
ts
import {Effect ,Config } from "effect"constprogram =Effect .gen (function* (_ ) {constconfig = yield*_ (Config .array (Config .string (), "MY_ARRAY"))console .log (config )})Effect .runSync (program )
bash
MY_ARRAY=a,b,c ts-node array.ts[ 'a', 'b', 'c' ]
bash
MY_ARRAY=a,b,c ts-node array.ts[ 'a', 'b', 'c' ]
Operators
Effect comes with a set of built-in operators to help you manipulate and handle configurations.
Transforming Operators
These operators allow you to transform a config into a new one:
validate
: Returns a config that describes the same structure as this one, but which performs validation during loading.map
: Creates a new config with the same structure as the original but with values transformed using a given function.mapAttempt
: Similar tomap
, but if the function throws an error, it's caught and turned into a validation error.mapOrFail
: Likemap
, but allows for functions that might fail. If the function fails, it results in a validation error.
Fallback Operators
These operators help you set up fallbacks in case of errors or missing data:
orElse
: Sets up a config that tries to use this config first. If there's an issue, it falls back to another specified config.orElseIf
: This one also tries to use the main config first but switches to a fallback config if there's an error that matches a specific condition.
Example
ts
import {Effect ,Config } from "effect"constprogram =Effect .gen (function* (_ ) {constconfig = yield*_ (Config .string ("NAME").pipe (Config .validate ({message : "Expected a string at least 4 characters long",validation : (s ) =>s .length >= 4})))console .log (config )})Effect .runSync (program )
ts
import {Effect ,Config } from "effect"constprogram =Effect .gen (function* (_ ) {constconfig = yield*_ (Config .string ("NAME").pipe (Config .validate ({message : "Expected a string at least 4 characters long",validation : (s ) =>s .length >= 4})))console .log (config )})Effect .runSync (program )
bash
NAME=foo ts-node validate.ts[(Invalid data at NAME: "Expected a string at least 4 characters long")]
bash
NAME=foo ts-node validate.ts[(Invalid data at NAME: "Expected a string at least 4 characters long")]
Custom Configurations
In addition to primitive types, we can also define configurations for custom types. To achieve this, we use primitive configs and combine them using Config
operators (zip
, orElse
, map
, etc.) and constructors (array
, hashSet
, etc.).
Let's consider the HostPort
data type, which consists of two fields: host
and port
.
ts
class HostPort {constructor(readonly host: string,readonly port: number) {}}
ts
class HostPort {constructor(readonly host: string,readonly port: number) {}}
We can define a configuration for this data type by combining primitive configs for string
and number
:
ts
import {Config } from "effect"export classHostPort {constructor(readonlyhost : string,readonlyport : number) {}geturl () {return `${this.host }:${this.port }`}}constboth =Config .all ([Config .string ("HOST"),Config .number ("PORT")])export constconfig =Config .map (both ,([host ,port ]) => newHostPort (host ,port ))
ts
import {Config } from "effect"export classHostPort {constructor(readonlyhost : string,readonlyport : number) {}geturl () {return `${this.host }:${this.port }`}}constboth =Config .all ([Config .string ("HOST"),Config .number ("PORT")])export constconfig =Config .map (both ,([host ,port ]) => newHostPort (host ,port ))
In the above example, we use the Config.all(configs)
operator to combine two primitive configs Config<string>
and Config<number>
into a Config<[string, number]>
.
If we use this customized configuration in our application:
ts
import {Effect } from "effect"import * asHostPort from "./HostPort"export constprogram =Effect .gen (function* (_ ) {consthostPort = yield*_ (HostPort .config )console .log (`Application started: ${hostPort .url }`)})
ts
import {Effect } from "effect"import * asHostPort from "./HostPort"export constprogram =Effect .gen (function* (_ ) {consthostPort = yield*_ (HostPort .config )console .log (`Application started: ${hostPort .url }`)})
when you run the program using Effect.runSync(program)
, it will attempt to read the corresponding values from environment variables (HOST
and PORT
):
bash
HOST=localhost PORT=8080 ts-node HostPort.tsApplication started: localhost:8080
bash
HOST=localhost PORT=8080 ts-node HostPort.tsApplication started: localhost:8080
Top-level and Nested Configurations
So far, we have learned how to define configurations in a top-level manner, whether they are for primitive or custom types. However, we can also define nested configurations.
Let's assume we have a ServiceConfig
data type that consists of two fields: hostPort
and timeout
.
ts
import * asHostPort from "./HostPort"import {Config } from "effect"classServiceConfig {constructor(readonlyhostPort :HostPort .HostPort ,readonlytimeout : number) {}}constconfig =Config .map (Config .all ([HostPort .config ,Config .number ("TIMEOUT")]),([hostPort ,timeout ]) => newServiceConfig (hostPort ,timeout ))
ts
import * asHostPort from "./HostPort"import {Config } from "effect"classServiceConfig {constructor(readonlyhostPort :HostPort .HostPort ,readonlytimeout : number) {}}constconfig =Config .map (Config .all ([HostPort .config ,Config .number ("TIMEOUT")]),([hostPort ,timeout ]) => newServiceConfig (hostPort ,timeout ))
If we use this customized config in our application, it tries to read corresponding values from environment variables: HOST
, PORT
, and TIMEOUT
.
However, in many cases, we don't want to read all configurations from the top-level namespace. Instead, we may want to nest them under a common namespace. For example, we want to read both HOST
and PORT
from the HOSTPORT
namespace, and TIMEOUT
from the root namespace.
To achieve this, we can use the Config.nested
combinator. It allows us to nest configs under a specific namespace. Here's how we can update our configuration:
ts
constconfig =Config .map (Config .all ([Config .nested (HostPort .config , "HOSTPORT"),Config .number ("TIMEOUT")]),([hostPort ,timeout ]) => newServiceConfig (hostPort ,timeout ))
ts
constconfig =Config .map (Config .all ([Config .nested (HostPort .config , "HOSTPORT"),Config .number ("TIMEOUT")]),([hostPort ,timeout ]) => newServiceConfig (hostPort ,timeout ))
Now, if we run our application, it will attempt to read the corresponding values from the environment variables: HOSTPORT_HOST
, HOSTPORT_PORT
, and TIMEOUT
.
Testing Services
When testing services, there are scenarios where we need to provide specific configurations to them. In such cases, we should be able to mock the backend that reads the configuration data.
To accomplish this, we can use the ConfigProvider.fromMap
constructor. This constructor takes a Map<string, string>
that represents the configuration data, and it returns a config provider that reads the configuration from that map.
Once we have the mock config provider, we can use Layer.setConfigProvider
function. This function allows us to override the default config provider and provide our own custom config provider. It returns a Layer
that can be used to configure the Effect runtime for our test specs.
Here's an example of how we can mock a config provider for testing purposes:
ts
import {ConfigProvider ,Layer ,Effect } from "effect"import * asApp from "./App"// Create a mock config provider using ConfigProvider.fromMapconstmockConfigProvider =ConfigProvider .fromMap (newMap ([["HOST", "localhost"],["PORT", "8080"]]))// Create a layer using Layer.setConfigProvider to override the default config providerconstlayer =Layer .setConfigProvider (mockConfigProvider )// Run the program using the provided layerEffect .runSync (Effect .provide (App .program ,layer ))// Output: Application started: localhost:8080
ts
import {ConfigProvider ,Layer ,Effect } from "effect"import * asApp from "./App"// Create a mock config provider using ConfigProvider.fromMapconstmockConfigProvider =ConfigProvider .fromMap (newMap ([["HOST", "localhost"],["PORT", "8080"]]))// Create a layer using Layer.setConfigProvider to override the default config providerconstlayer =Layer .setConfigProvider (mockConfigProvider )// Run the program using the provided layerEffect .runSync (Effect .provide (App .program ,layer ))// Output: Application started: localhost:8080
By using this approach, we can easily mock the configuration data and test our services with different configurations in a controlled manner.
Secret
What sets Config.secret
apart from Config.string
is its handling of sensitive information.
It parses the Config value and wraps it in a Secret
, a data type designed for holding secrets.
When you use console.log
on a secret, the actual value remains hidden, providing an added layer of security. The only way to access the value is by using Secret.value(secret)
.
Here's a simple example to illustrate:
ts
import {Effect ,Config ,ConfigProvider ,Layer ,Console ,Secret } from "effect"constprogram =Config .secret ("API_KEY").pipe (Effect .tap ((secret ) =>Console .log (`console.log: ${secret }`)),Effect .tap ((secret ) =>Console .log (`Secret.value: ${Secret .value (secret )}`)))Effect .runSync (program .pipe (Effect .provide (Layer .setConfigProvider (ConfigProvider .fromMap (newMap ([["API_KEY", "my-api-key"]]))))))/*Output:console.log: Secret(<redacted>)Secret.value: my-api-key*/
ts
import {Effect ,Config ,ConfigProvider ,Layer ,Console ,Secret } from "effect"constprogram =Config .secret ("API_KEY").pipe (Effect .tap ((secret ) =>Console .log (`console.log: ${secret }`)),Effect .tap ((secret ) =>Console .log (`Secret.value: ${Secret .value (secret )}`)))Effect .runSync (program .pipe (Effect .provide (Layer .setConfigProvider (ConfigProvider .fromMap (newMap ([["API_KEY", "my-api-key"]]))))))/*Output:console.log: Secret(<redacted>)Secret.value: my-api-key*/
In this example, you can see that when logging the secret using console.log
, the actual value is replaced with <redacted>
, ensuring that sensitive information is not exposed. The Secret.value
function, on the other hand, provides a controlled way to retrieve the original secret value.