Skip to content

LayerMap

Ordinary layers build a fixed set of services. But some applications need a service instance per key that isn’t known ahead of time: a database client per tenant, a connection per region, an API client per account. LayerMap is a scoped, reference-counted cache that builds a layer on demand for each key, reuses it while it’s live, and finalizes it when it’s no longer needed.

import { Context, Effect, Layer, LayerMap } from "effect"
// The per-key service we want a distinct instance of.
class Greeter extends Context.Service<Greeter, {
readonly greet: Effect.Effect<string>
}>()("myapp/Greeter") {}
// A LayerMap.Service wraps the cache and exposes accessor helpers on the class.
// `lookup` is called once per distinct key to produce that key's layer.
class GreeterMap extends LayerMap.Service<GreeterMap>()("myapp/GreeterMap", {
lookup: (name: string) =>
Layer.succeed(Greeter, {
greet: Effect.succeed(`Hello, ${name}!`)
}),
// Entries unused for this long are released automatically.
idleTimeToLive: "5 seconds"
}) {}

To use a keyed resource, provide the layer for a specific key with GreeterMap.get(key). That layer makes the Greeter service available, built from the cache:

import { Console, Effect } from "effect"
import { Greeter } from "./Greeter.ts"
import { GreeterMap } from "./GreeterMap.ts"
const program = Effect.gen(function*() {
const greeter = yield* Greeter
yield* Console.log(yield* greeter.greet)
}).pipe(
// `get("John")` provides a Greeter built for the key "John". Asking again for
// "John" reuses the cached instance; a different key builds a new one.
Effect.provide(GreeterMap.get("John")),
// The map itself is a layer too — provide it once at the top.
Effect.provide(GreeterMap.layer)
)
Effect.runFork(program)

LayerMap.Service<Self>()("id", options) produces a class that is both a normal service (so it has a .layer) and a façade over the underlying cache. The accessor helpers it generates:

  • GreeterMap.get(key) — a Layer that provides the key’s services, building them on first use and reusing the cached instance afterwards.
  • GreeterMap.contextEffect(key) — the acquired Context directly, for when you want the services as a value rather than a layer (requires a Scope).
  • GreeterMap.invalidate(key) — finalize the current cached entry for a key; the next access rebuilds it.

The cache is reference-counted: while anything is using a key’s resources they stay live, and they’re finalized once nobody holds them and idleTimeToLive elapses.

The lookup function returns a full layer, so each key can have its own configured resource — including one with dependencies and cleanup:

import { Context, Effect, Layer, LayerMap } from "effect"
class TenantDb extends Context.Service<TenantDb, {
query(sql: string): Effect.Effect<ReadonlyArray<unknown>>
}>()("myapp/TenantDb") {}
class TenantDbMap extends LayerMap.Service<TenantDbMap>()("myapp/TenantDbMap", {
// Build a distinct, resource-owning layer per tenant id.
lookup: (tenantId: string) =>
Layer.effect(
TenantDb,
Effect.gen(function*() {
// Each tenant gets its own connection, released when the entry expires.
const conn = yield* Effect.acquireRelease(
Effect.sync(() => ({ tenantId, close: () => {} })),
(c) => Effect.sync(() => c.close())
)
return TenantDb.of({
query: (sql) => Effect.succeed([{ tenantId: conn.tenantId, sql }])
})
})
),
idleTimeToLive: "30 seconds"
}) {}
// Handle a request scoped to one tenant.
const handle = (tenantId: string) =>
Effect.gen(function*() {
const db = yield* TenantDb
return yield* db.query("SELECT * FROM orders")
}).pipe(
Effect.provide(TenantDbMap.get(tenantId)),
Effect.provide(TenantDbMap.layer)
)

When the keys are known and finite, pass a layers record instead of a lookup function. The key type becomes the union of record keys:

import { Context, Effect, Layer, LayerMap } from "effect"
import { Greeter } from "./Greeter.ts"
class RegionGreeters extends LayerMap.Service<RegionGreeters>()("myapp/RegionGreeters", {
layers: {
us: Layer.succeed(Greeter, { greet: Effect.succeed("Hi from US") }),
eu: Layer.succeed(Greeter, { greet: Effect.succeed("Hallo from EU") })
}
}) {}
// RegionGreeters.get("us") | "eu" — anything else is a compile error.

Dependencies, idle eviction, and preloading

Section titled “Dependencies, idle eviction, and preloading”

The service options accept a few more fields that shape how the map is built and how its entries live and die.

  • dependencies — layers the lookup/record layers need. They are provided to .layer (but not .layerNoDeps), so the resulting LayerMap layer has no outstanding requirements.
  • idleTimeToLive — how long an entry survives after its reference count hits zero before being finalized. Omit it (the default is 0) and an entry is released the instant its last reference is gone. Pass a Duration.Input for a fixed window, or a function (key) => Duration.Input to vary it per key.
  • preloadKeys (lookup form) / preload: true (record form) — eagerly build the listed entries when the map is created. This moves layer construction errors to LayerMap creation time instead of first use, which is why preloading adds the layers’ error type to .layer’s error channel.
import { Context, Effect, Layer, LayerMap } from "effect"
class Config extends Context.Service<Config, { readonly url: string }>()(
"myapp/Config"
) {}
class ClientMap extends LayerMap.Service<ClientMap>()("myapp/ClientMap", {
lookup: (region: "us" | "eu") =>
Layer.succeed(Config, { url: `https://${region}.example.com` }),
// Provided to `ClientMap.layer`, so the layer requires nothing extra.
dependencies: [],
// "us" lives 1 minute idle; "eu" lives 5 minutes idle.
idleTimeToLive: (region) => (region === "us" ? "1 minute" : "5 minutes"),
// Build both entries up front; failures surface here, not on first get.
preloadKeys: ["us", "eu"]
}) {}

A LayerMap is, under the hood, an RcMap<K, Context<I>, E>: a scoped, reference-counted map. Each get(key) increments the entry’s reference count for the current scope and adds a release finalizer to it. When that scope closes the reference is released; when the count reaches zero the entry is either closed immediately (default, idleTimeToLive of 0) or kept warm until the idle window elapses. Building the same key while it is live shares the cached Context instead of rebuilding it.

idleTimeToLive controls that warm window: a fixed Duration.Input, a (key) => Duration.Input function, or — when infinite — an entry that never expires on its own (only invalidate or the map’s scope closing releases it).

LayerMap delegates lifecycle management to the RcMap module. You rarely need it directly, but .rcMap lets you use these combinators on the live entries:

Returns an iterable of all keys that currently have a stored entry. Interrupts if the map’s scope has closed.

import { Effect, RcMap } from "effect"
declare const map: RcMap.RcMap<string, unknown>
Effect.gen(function*() {
const ks = yield* RcMap.keys(map)
console.log([...ks]) // => ["us", "eu"]
})

Checks whether a key currently has a stored entry, without running the lookup. Returns false for both missing keys and closed maps.

import { Effect, RcMap } from "effect"
declare const map: RcMap.RcMap<string, unknown>
Effect.gen(function*() {
const present = yield* RcMap.has(map, "us")
console.log(present) // => true
})

Resets the idle expiration timer for a key (when idleTimeToLive is configured), keeping a warm entry alive longer without acquiring a new reference.

import { Effect, RcMap } from "effect"
declare const map: RcMap.RcMap<string, unknown>
Effect.gen(function*() {
yield* RcMap.touch(map, "us") // => idle timer reset for "us"
})

Removes a key and releases it if it has no live references. This is what LayerMap’s .invalidate(key) calls under the hood.

import { Effect, RcMap } from "effect"
declare const map: RcMap.RcMap<string, unknown>
Effect.gen(function*() {
yield* RcMap.invalidate(map, "us") // => "us" removed; rebuilt on next get
})

LayerMap builds on the same caching machinery as Caching and the scope rules from Resource Management; reach for it specifically when the identity of a resource is a runtime key.

Standalone constructor: builds a scoped LayerMap from a lookup function. Use this when you want the LayerMap value directly (e.g. inside another layer) rather than as its own service class. Returns an Effect that yields the map and requires a Scope.

import { Context, Effect, Layer, LayerMap } from "effect"
class Db extends Context.Service<Db, {
readonly query: (sql: string) => Effect.Effect<string>
}>()("Db") {}
const program = Effect.gen(function*() {
const map = yield* LayerMap.make(
(env: string) =>
Layer.succeed(Db, {
query: (sql) => Effect.succeed(`${env}: ${sql}`)
}),
{ idleTimeToLive: "5 seconds" }
)
const result = yield* Effect.gen(function*() {
const db = yield* Db
return yield* db.query("SELECT 1")
}).pipe(Effect.provide(map.get("development")))
console.log(result) // => "development: SELECT 1"
}).pipe(Effect.scoped)

Standalone constructor for a fixed set of keys: the record’s keys become the map’s key type, and its values are the layers built for those keys. Accepts idleTimeToLive and preload.

import { Context, Effect, Layer, LayerMap } from "effect"
import { Db } from "./Db.ts"
const program = Effect.gen(function*() {
const map = yield* LayerMap.fromRecord(
{
dev: Layer.succeed(Db, { query: (s) => Effect.succeed(`DEV: ${s}`) }),
prod: Layer.succeed(Db, { query: (s) => Effect.succeed(`PROD: ${s}`) })
},
{ idleTimeToLive: "10 seconds" }
)
const layer = map.get("dev") // => Layer<Db>
}).pipe(Effect.scoped)

Defines a service class that wraps a LayerMap. Call it as LayerMap.Service<Self>()(id, options) where options is either the lookup form { lookup, dependencies?, idleTimeToLive?, preloadKeys? } or the record form { layers, dependencies?, idleTimeToLive?, preload? }. The class is a Context.Service tag and carries the static accessor helpers below.

import { Context, Effect, Layer, LayerMap } from "effect"
class Greeter extends Context.Service<Greeter, {
readonly greet: Effect.Effect<string>
}>()("Greeter") {}
class GreeterMap extends LayerMap.Service<GreeterMap>()("GreeterMap", {
lookup: (name: string) =>
Layer.succeed(Greeter, { greet: Effect.succeed(`Hello, ${name}!`) }),
idleTimeToLive: "5 seconds"
}) {}
// The class is itself a service tag plus the static helpers .layer/.get/etc.

A Layer that provides the key’s services, built on first access and reused while cached. Provide it to the program that needs the keyed services.

import { Console, Effect } from "effect"
import { Greeter } from "./Greeter.ts"
import { GreeterMap } from "./GreeterMap.ts"
const useGreeter = Effect.gen(function*() {
const greeter = yield* Greeter
yield* Console.log(yield* greeter.greet) // => "Hello, John!"
}).pipe(
Effect.provide(GreeterMap.get("John")),
Effect.provide(GreeterMap.layer)
)

Returns the acquired Context for a key directly, as an effectful value rather than a layer. Requires a Scope (plus the service tag), since the returned context’s resources stay live for the surrounding scope.

import { Context, Effect } from "effect"
import { Greeter } from "./Greeter.ts"
import { GreeterMap } from "./GreeterMap.ts"
const program = Effect.gen(function*() {
// The acquired Context<Greeter>, kept live for the surrounding scope.
const ctx = yield* GreeterMap.contextEffect("Jane")
// Read a service out of it directly...
const greeter = Context.get(ctx, Greeter)
console.log(yield* greeter.greet) // => "Hello, Jane!"
// ...or provide the whole context to an effect.
const msg = yield* Greeter.pipe(
Effect.flatMap((g) => g.greet),
Effect.provideContext(ctx)
)
console.log(msg) // => "Hello, Jane!"
}).pipe(Effect.scoped, Effect.provide(GreeterMap.layer))

Finalizes the current cached entry for a key and removes it from the map; the next get/contextEffect rebuilds it. Useful for forcing a reconnect or discarding a stale resource. If the entry still has live references it is removed from the map but only finalized once those references are released.

import { Effect } from "effect"
import { GreeterMap } from "./GreeterMap.ts"
const refresh = Effect.gen(function*() {
// Drop the cached "John" entry; the next get("John") builds a fresh one.
yield* GreeterMap.invalidate("John")
}).pipe(Effect.provide(GreeterMap.layer))

The default layer that constructs the LayerMap service, with any dependencies already provided. Provide this once near the root so the accessor layers (get, etc.) have a map to read from.

import { Effect } from "effect"
import { GreeterMap } from "./GreeterMap.ts"
const main = Effect.void.pipe(Effect.provide(GreeterMap.layer))
// => Layer<GreeterMap> (dependencies, if any, already satisfied)

Same as .layer but without the configured dependencies provided — so its requirement channel still lists those dependency services. Use it when you want to wire the dependencies yourself (e.g. share a single dependency layer across several services).

import { Layer } from "effect"
import { ClientMap } from "./ClientMap.ts"
// Provide dependencies your own way instead of relying on options.dependencies.
const wired = Layer.provide(ClientMap.layerNoDeps, [/* your dep layers */])

On the LayerMap value (from make / fromRecord), exposes the underlying RcMap that stores the reference-counted contexts. Drop to this layer to inspect keys or extend idle lifetimes with the RcMap combinators.

import { Effect, LayerMap, RcMap } from "effect"
const inspect = (map: LayerMap.LayerMap<string, never>) =>
Effect.gen(function*() {
const keys = yield* RcMap.keys(map.rcMap)
console.log([...keys]) // => the keys with live/cached entries
})