Requirements Management #
Services, the R channel, and Layers: dependencies tracked by the compiler and satisfied once at the edge.
The problem. Every real program needs things that it did not create: a database, a logger, a clock, a config. Plain TypeScript gives you 3 ways to get them. Each way has a cost.
// 1. Pass everything down, five levels deep
async function placeOrder(order: Order, db: Database, logger: Logger, config: Config) {
return chargeCard(order, db, logger, config) // chargeCard passes them on again...
}
// 2. Reach for a module-level singleton
import { db } from "./db" // connects the moment the file is imported
async function placeOrder(order: Order) { return db.insert(order) } // untestable
// 3. Ask a DI container by name at runtime
const db = container.resolve<Database>("Database") // typo in the string? Crashes at startup, or laterOption 1 puts unrelated arguments in every signature. Option 2 hides the dependency completely. A test cannot replace it, and the connection opens at import time. Option 3 checks nothing at compile time. In all 3 options, the type does not answer the question "what does this function need to run?".
The shift
Today you think of a dependency as a value that you pass in or get from a module. Effect asks you to think of it as a requirement recorded in the type. When code reads the Database service, Database appears in the third type parameter, R. When you combine 2 effects, Effect combines their requirements. At the top of the program, R must be never. This means that every requirement is satisfied. If 1 requirement is missing, the program does not compile.
You satisfy a requirement once, at the top of the program, with a Layer. A layer is a value that describes how to build a service, possibly from other services. You combine layers into a graph. Effect builds the graph once. Effect builds each service in the graph 1 time and shares it.
The result: business logic never says how a service is built. To replace a real mailer with a fake one in tests, you change 1 line at the top of the program. Config does not travel through 5 signatures. The compiler tells you what is missing, not a crash at startup.
| Arguments | Module singleton | DI container | Effect | |
|---|---|---|---|---|
| Visible in the type | Yes, but with many arguments | No | No | Yes, in R |
| Replaceable in tests | Yes, with much work | No | Yes | Yes, provide a different layer |
| Missing dependency found | Compile time | Never | Runtime | Compile time |
| Construction order | Manual | Import order | Container | From the layer graph |
| Built once and shared | Manual | Yes | Usually | Yes, memoized in each graph |
In this section, you:
- define services
- read
R - build layers
- connect a small graph
- replace implementations
- see memoization
Learn #
Lesson 1. A requirement shows up in R #
A service is 2 things together: a tag and a shape. The tag is a unique key that you use as a value. The shape is the TypeScript type of the value that the key points to. The class syntax below defines both at the same time. The class Logger is the tag. The object type between the angle brackets is the shape.
Inside Effect.gen, yield* Logger does 2 things. At runtime, it gets the implementation. At compile time, it adds Logger to the R of the effect around it. program does not say how to log. It says only that it needs a logger.
Effect.provideService satisfies 1 requirement with 1 call. Give it the tag and an implementation, and Effect removes Logger from R. Only then does runSync accept the program.
import { Context, Effect } from "effect"
// The tag is the class. The shape is the object type in the angle brackets.
class Logger extends Context.Service<Logger, {
readonly log: (message: string) => Effect.Effect<void>
}>()("Logger") {}
// yield* on the tag returns the implementation and records Logger in R
const program = Effect.gen(function* () {
const logger = yield* Logger
yield* logger.log("order 42 placed")
yield* logger.log("receipt emailed")
})
// program: Effect<void, never, Logger>
// Satisfy the requirement once, at the edge. R becomes never.
const runnable = program.pipe(
Effect.provideService(Logger, {
log: (message) => Effect.sync(() => console.log("[log]", message))
})
)
Effect.runSync(runnable)
Change Effect.runSync(runnable) to Effect.runSync(program). The compiler reports: Type 'Logger' is not assignable to type 'never'. This is the purpose of the R channel: a missing dependency is a type error, not a crash at the first request.
Lesson 2. Three ways to declare a service #
The class syntax is the default. 2 more forms exist for specific situations.
| Form | Write it as | Use when |
|---|---|---|
| Class | class Db extends Context.Service<Db, Shape>()("Db") {} |
Almost always. The class is a clean identifier in R. |
| Function | const Clock = Context.Service<Clock>("Clock") |
You already have an interface named Clock and want 1 line. |
| Reference | Context.Reference<T>("Tz", { defaultValue }) |
The service has a good default. You override it only sometimes. |
In the function form, the interface is the identifier, so R shows Clock. Note: 2 services with the same string key but different shapes collide at runtime. Keep the keys unique.
A Reference is different. It always has a default. When you read it, it adds nothing to R. You can run the program without it, and you can still override it with provideService when you want to. Config values and feature flags are the usual use.
import { Context, Effect } from "effect"
// Function syntax: the interface is the shape, the string is the key
interface Clock {
readonly now: () => number
}
const Clock = Context.Service<Clock>("Clock")
// A Reference is a service with a default. It never appears in R.
const Timezone = Context.Reference<string>("Timezone", { defaultValue: () => "UTC" })
const program = Effect.gen(function* () {
const clock = yield* Clock // adds Clock to R
const tz = yield* Timezone // adds nothing to R, the default is used
console.log("time", clock.now(), "zone", tz)
})
// program: Effect<void, never, Clock>
// Same program, two environments. Overriding the Reference is optional.
Effect.runSync(program.pipe(Effect.provideService(Clock, { now: () => 1000 })))
Effect.runSync(program.pipe(
Effect.provideService(Clock, { now: () => 2000 }),
Effect.provideService(Timezone, "Europe/Berlin")
))
There are 2 equivalent ways to read a service outside Effect.gen. Effect.service(Clock) returns the same effect as yield* Clock. Clock.use((c) => Effect.succeed(c.now())) reads the service and calls a method in 1 step. In generators, use yield*. It keeps the requirement visible where you use it.
Lesson 3. Layers: a recipe for building a service #
provideService takes an implementation that you already hold. Real services must be built: read the config, open a connection, print a startup line. A Layer is an effect that builds a service. Compare the plain TypeScript main that you have written many times:
async function main() {
console.log("connecting to database")
const db = await Database.connect() // built here, by hand, in the right order
await runApp(db) // and threaded into everything
}With Effect, the construction becomes a value, DatabaseLive. Effect.provide connects it to the program. 2 constructors cover most cases:
| Constructor | Takes | Use when |
|---|---|---|
Layer.succeed(Tag, impl) |
A plain value | The implementation needs no setup |
Layer.effect(Tag, effect) |
An effect that returns the implementation | The implementation needs setup: config, connections, logs |
Read the type Layer<Database, never, never> as "provides Database, cannot fail when it builds, needs nothing to build."
import { Context, Effect, Layer } from "effect"
class Database extends Context.Service<Database, {
readonly query: (sql: string) => Effect.Effect<Array<string>>
}>()("Database") {}
// Layer.effect: building the service is itself an Effect.
// It runs once, when the layer is built, not on every query.
const DatabaseLive = Layer.effect(
Database,
Effect.sync(() => {
console.log("connecting to database")
return {
query: (sql) => Effect.succeed(["row for: " + sql])
}
})
)
// DatabaseLive: Layer<Database, never, never>
const program = Effect.gen(function* () {
const db = yield* Database
const users = yield* db.query("select * from users")
const orders = yield* db.query("select * from orders")
console.log(users[0])
console.log(orders[0])
})
// program: Effect<void, never, Database>
// Effect.provide plugs the layer in. R becomes never.
Effect.runSync(program.pipe(Effect.provide(DatabaseLive)))
"connecting to database" printed once, but query ran 2 times. The build effect of the layer runs when Effect builds the graph. Then Effect gives the same implementation to every yield* Database. For a service that needs no setup, replace Layer.effect with Layer.succeed(Database, { query: ... }).
Lesson 4. A graph of layers: provide, provideMerge, merge #
A layer built with Layer.effect can itself yield* other services. Those services become the requirements of the layer. Layer<UserRepo, never, Database> means "provides UserRepo, but needs Database to build." You connect layers from the bottom up with 3 functions.
| Function | Meaning | What the result provides |
|---|---|---|
A.pipe(Layer.provide(B)) |
B provides its service to A | Only A. B is hidden. |
A.pipe(Layer.provideMerge(B)) |
B provides its service to A | A and B. |
Layer.merge(A, B) |
No relation between A and B | A and B. |
The diagram below shows what the code builds. Each arrow is 1 Layer.provide. The top is what program needs:
program needs: UserRepo, Logger
|
+------+------+
| |
UserRepoLive LoggerLive
| needs Database
DatabaseLive
| needs Config
ConfigLiveIf you forget 1 arrow, for example ConfigLive, the requirement moves up to the top. The compiler reports Type 'Config' is not assignable to type 'never' at the runSync call.
import { Context, Effect, Layer } from "effect"
class Config extends Context.Service<Config, { readonly dbUrl: string }>()("Config") {}
class Database extends Context.Service<Database, {
readonly query: (sql: string) => Effect.Effect<Array<string>>
}>()("Database") {}
class UserRepo extends Context.Service<UserRepo, {
readonly findName: (id: number) => Effect.Effect<string>
}>()("UserRepo") {}
class Logger extends Context.Service<Logger, {
readonly log: (message: string) => Effect.Effect<void>
}>()("Logger") {}
const ConfigLive = Layer.succeed(Config, { dbUrl: "postgres://prod" })
// Needs Config: Layer<Database, never, Config>
const DatabaseLive = Layer.effect(Database, Effect.gen(function* () {
const config = yield* Config
return {
query: (sql) => Effect.sync(() => {
console.log("query on", config.dbUrl + ":", sql)
return ["Ada"]
})
}
}))
// Needs Database: Layer<UserRepo, never, Database>
const UserRepoLive = Layer.effect(UserRepo, Effect.gen(function* () {
const db = yield* Database
return {
findName: (id) => db.query("select name from users where id = " + id).pipe(Effect.map((rows) => rows[0]!))
}
}))
const LoggerLive = Layer.succeed(Logger, {
log: (message) => Effect.sync(() => console.log("[log]", message))
})
// Wire bottom-up. Each provide removes one requirement from the layer above it.
const AppLive = UserRepoLive.pipe(
Layer.provide(DatabaseLive.pipe(Layer.provide(ConfigLive))), // Layer<UserRepo, never, never>
Layer.merge(LoggerLive) // Layer<UserRepo | Logger, never, never>
)
const program = Effect.gen(function* () {
const repo = yield* UserRepo
const logger = yield* Logger
const name = yield* repo.findName(1)
yield* logger.log("found " + name)
})
Effect.runSync(program.pipe(Effect.provide(AppLive)))
program cannot yield* Config now, because Layer.provide hides it. Change the inner Layer.provide(ConfigLive) to Layer.provideMerge(ConfigLive). Change the outer Layer.provide to Layer.provideMerge also. Then Config becomes available to program. Usually you want to hide it: the app must not know which database URL the repository uses.
Lesson 5. Swapping implementations: real versus fake #
This lesson shows the largest benefit. signUp below needs a Mailer. It does not know if the mailer sends to an SMTP server or writes to an array. The same signUp runs with both. The only difference is which layer you provide at the top of the program.
In plain TypeScript, this needs constructor injection in every class, or a module mock in the test runner that patches import at load time. Here it is a normal value: a layer. The test layer is often shorter than the mock setup. The compiler checks it against the same shape as production, so the fake cannot become different from production.
The Effect codebase uses this convention for names: Mailer.layer for the primary implementation, and a descriptive suffix for variants, for example layerTest. This lesson uses MailerLive and MailerFake to make the difference clear.
import { Context, Effect, Layer } from "effect"
class Mailer extends Context.Service<Mailer, {
readonly send: (to: string, subject: string) => Effect.Effect<void>
}>()("Mailer") {}
// Business logic. Knows nothing about SMTP or fakes: Effect<void, never, Mailer>
const signUp = (email: string) =>
Effect.gen(function* () {
const mailer = yield* Mailer
yield* mailer.send(email, "Welcome")
console.log("signed up", email)
})
// Production: talks to the outside world
const MailerLive = Layer.succeed(Mailer, {
send: (to, subject) => Effect.sync(() => console.log("SMTP: sending", subject, "to", to))
})
// Test: records what would have been sent, sends nothing
const sent: Array<string> = []
const MailerFake = Layer.succeed(Mailer, {
send: (to, subject) => Effect.sync(() => { sent.push(subject + " -> " + to) })
})
// Same program, different edge
Effect.runSync(signUp("ada@example.com").pipe(Effect.provide(MailerLive)))
Effect.runSync(signUp("lin@example.com").pipe(Effect.provide(MailerFake)))
console.log("fake recorded:", sent.join(", "))
Remove the send property from MailerFake. The program does not compile, because Layer.succeed checks the implementation against the shape declared on the tag. The compiler finds a fake that does not match production before any test runs.
Lesson 6. Layers are built once: memoization #
When 2 layers in the same graph both depend on Config, you can expect Effect to build Config 2 times. It does not. Effect memoizes layers by identity. Memoization means that Effect keeps the result of the first build and reuses it. Effect builds the same layer value once, even when 2 places in 1 graph use it, and shares the result.
AppLive (merge)
/ \
DatabaseLive CacheLive
| |
ConfigLive == ConfigLive same value, built onceThis matters for anything expensive or stateful: a connection pool, a metrics client, a cache. Every service in the graph gets the same instance. A module singleton also gives you this, but with side effects at import time and no way to replace it.
The Effect team gives this rule: combine the whole graph with Layer.provide and Layer.merge, then call Effect.provide once. Memoization is a safety measure, not a replacement for correct composition.
import { Context, Effect, Layer } from "effect"
class Config extends Context.Service<Config, { readonly appName: string }>()("Config") {}
class Database extends Context.Service<Database, { readonly label: string }>()("Database") {}
class Cache extends Context.Service<Cache, { readonly label: string }>()("Cache") {}
// The print proves how many times this layer is built
const ConfigLive = Layer.effect(Config, Effect.sync(() => {
console.log("building Config")
return { appName: "shop" }
}))
const DatabaseLive = Layer.effect(Database, Effect.gen(function* () {
const config = yield* Config
return { label: "db for " + config.appName }
})).pipe(Layer.provide(ConfigLive))
const CacheLive = Layer.effect(Cache, Effect.gen(function* () {
const config = yield* Config
return { label: "cache for " + config.appName }
})).pipe(Layer.provide(ConfigLive))
const program = Effect.gen(function* () {
const db = yield* Database
const cache = yield* Cache
console.log(db.label)
console.log(cache.label)
})
// ConfigLive appears twice in this graph, but is built once
Effect.runSync(program.pipe(Effect.provide(Layer.merge(DatabaseLive, CacheLive))))
To force a second build, for example to give a test its own isolated pool, wrap 1 occurrence in Layer.fresh(ConfigLive). Then "building Config" prints 2 times. Memoization applies to 1 graph. If you split 1 program into 2 separate Effect.provide calls that run one after the other, each call builds its own copy. The correction is always the same: combine first, provide once.
Do and don't #
| Do | Don't | Why |
|---|---|---|
Read a service with yield* Tag inside Effect.gen. | Do not import a module-level singleton inside business logic. | A singleton hides the dependency, so R does not show it, and a test cannot replace it. |
Use Layer.effect(Tag, effect) when the implementation needs setup. | Do not pass an effect to Layer.succeed. | Layer.succeed stores the effect itself as the service, so the service has no methods, and the program does not compile. |
Combine the whole layer graph first, then call Effect.provide once at the top. | Do not call Effect.provide on each step of the program. | Each Effect.provide builds its own graph, so Effect builds an expensive layer once for each call. |
Use Layer.provide to hide an internal dependency, for example a database URL. | Do not use Layer.provideMerge for every connection in the graph. | provideMerge exposes the dependency, so code that uses the graph can depend on an internal detail. |
Use Context.Reference with a defaultValue for config and flags that have a good default. | Do not declare an optional setting with Context.Service. | Context.Service adds a hard requirement to R, so the program does not compile without a provider. |
| Give each service a unique string key. | Do not use the same string key for 2 services with different shapes. | The 2 services collide at runtime, and one implementation replaces the other. |
Use Layer.fresh only when a test needs its own separate instance. | Do not use Layer.fresh to correct a graph that builds a layer 2 times. | The cause is 2 separate Effect.provide calls, and Layer.fresh makes more builds, not fewer. |
Fix it #
Each program below is broken or incomplete. Make it print the expected output with zero type errors. Use hints before the solution.
1. Nobody provided it #
The program is correct but does not compile. It needs a Clock, and nothing provides one. Satisfy the requirement so that the program prints the time is 42. Do not change program.
import { Context, Effect } from "effect"
class Clock extends Context.Service<Clock, {
readonly now: () => number
}>()("Clock") {}
const program = Effect.gen(function* () {
const clock = yield* Clock
console.log("the time is", clock.now())
})
Effect.runSync(program)
2. A recipe where a value was expected #
DatabaseLive must print connecting once and then answer queries, but it does not compile. Correct the layer constructor so that the program prints the 2 lines below.
import { Context, Effect, Layer } from "effect"
class Database extends Context.Service<Database, {
readonly query: (sql: string) => Effect.Effect<string>
}>()("Database") {}
const DatabaseLive = Layer.succeed(
Database,
Effect.sync(() => {
console.log("connecting")
return { query: (sql: string) => Effect.succeed("rows for " + sql) }
})
)
const program = Effect.gen(function* () {
const db = yield* Database
console.log(yield* db.query("select 1"))
})
Effect.runSync(program.pipe(Effect.provide(DatabaseLive)))
3. The missing edge in the graph #
DatabaseLive needs Config to build, and the graph does not provide it, so the program does not compile. Connect ConfigLive to the graph so that the program prints rows from postgres://prod. Do not change program or the runSync line.
import { Context, Effect, Layer } from "effect"
class Config extends Context.Service<Config, { readonly dbUrl: string }>()("Config") {}
class Database extends Context.Service<Database, {
readonly query: (sql: string) => Effect.Effect<string>
}>()("Database") {}
const ConfigLive = Layer.succeed(Config, { dbUrl: "postgres://prod" })
const DatabaseLive = Layer.effect(Database, Effect.gen(function* () {
const config = yield* Config
return { query: (sql) => Effect.succeed("rows from " + config.dbUrl) }
}))
const AppLive = DatabaseLive
const program = Effect.gen(function* () {
const db = yield* Database
console.log(yield* db.query("select 1"))
})
Effect.runSync(program.pipe(Effect.provide(AppLive)))
4. Hidden by provide #
program needs both UserRepo and Config, but the graph exposes only UserRepo, so it does not compile. Change 1 layer function so that the graph exposes both, and the program prints the 2 lines below.
import { Context, Effect, Layer } from "effect"
class Config extends Context.Service<Config, { readonly env: string }>()("Config") {}
class UserRepo extends Context.Service<UserRepo, {
readonly find: (id: number) => Effect.Effect<string>
}>()("UserRepo") {}
const ConfigLive = Layer.succeed(Config, { env: "staging" })
const UserRepoLive = Layer.effect(UserRepo, Effect.gen(function* () {
const config = yield* Config
return { find: (id) => Effect.succeed("user " + id + " from " + config.env) }
}))
const AppLive = UserRepoLive.pipe(Layer.provide(ConfigLive))
const program = Effect.gen(function* () {
const repo = yield* UserRepo
const config = yield* Config
console.log(yield* repo.find(7))
console.log("running in", config.env)
})
Effect.runSync(program.pipe(Effect.provide(AppLive)))
5. Built twice #
Config is expensive to build, and this program builds it 2 times. Change the structure of the program so that it prints building Config exactly once, then the 2 uses lines in the same order.
import { Context, Effect, Layer } from "effect"
class Config extends Context.Service<Config, { readonly appName: string }>()("Config") {}
const ConfigLive = Layer.effect(Config, Effect.sync(() => {
console.log("building Config")
return { appName: "shop" }
}))
const stepA = Effect.gen(function* () {
const config = yield* Config
console.log("A uses", config.appName)
})
const stepB = Effect.gen(function* () {
const config = yield* Config
console.log("B uses", config.appName)
})
const program = Effect.gen(function* () {
yield* stepA.pipe(Effect.provide(ConfigLive))
yield* stepB.pipe(Effect.provide(ConfigLive))
})
Effect.runSync(program)
6. It must have a default #
Settings must be optional. The program must run without it and print verbose: false. Then it must run again with an override and print verbose: true. Now it does not compile. Change how Settings is declared. Do not change program or the 2 runSync lines.
import { Context, Effect } from "effect"
interface Settings {
readonly verbose: boolean
}
const Settings = Context.Service<Settings>("Settings")
const program = Effect.gen(function* () {
const settings = yield* Settings
console.log("verbose:", settings.verbose)
})
Effect.runSync(program)
Effect.runSync(program.pipe(Effect.provideService(Settings, { verbose: true })))
Build it #
Write the program from the spec. The output must match exactly.
1. Order pipeline with three services #
Build placeOrder(sku, qty) on top of 3 services, then connect them with layers.
Pricinghasprice(sku): number. Prices:bookis 15,penis 2.Inventoryhasreserve(sku, qty): Effect<void>. It printsreserved <qty> x <sku>.Notifierhasnotify(message): Effect<void>. It printsnotify: <message>.
placeOrder reserves stock, computes total = price * qty, prints total <total>, and notifies with the message order <sku> x<qty> = <total>. Build 1 layer for each service with Layer.succeed. Combine them with Layer.mergeAll. Run placeOrder("book", 2) and then placeOrder("pen", 5) with 1 Effect.provide. Exact output:
reserved 2 x book
total 30
notify: order book x2 = 30
reserved 5 x pen
total 10
notify: order pen x5 = 10import { Context, Effect, Layer } from "effect"
class Pricing extends Context.Service<Pricing, {
readonly price: (sku: string) => number
}>()("Pricing") {}
class Inventory extends Context.Service<Inventory, {
readonly reserve: (sku: string, qty: number) => Effect.Effect<void>
}>()("Inventory") {}
class Notifier extends Context.Service<Notifier, {
readonly notify: (message: string) => Effect.Effect<void>
}>()("Notifier") {}
// TODO: placeOrder(sku, qty): Effect<void, never, Pricing | Inventory | Notifier>
const placeOrder = (sku: string, qty: number) => Effect.void
// TODO: PricingLive, InventoryLive, NotifierLive with Layer.succeed
// TODO: AppLive = Layer.mergeAll(...)
const program = Effect.gen(function* () {
yield* placeOrder("book", 2)
yield* placeOrder("pen", 5)
})
// TODO: provide AppLive and run
2. A test double for the payment gateway #
checkout(amount) charges a card and prints the receipt id. It must not know which gateway it uses.
- Define a
Paymentsservice withcharge(amount): Effect<string>.chargereturns a receipt id. PaymentsLiveprintsstripe: charging <amount>and returns"stripe-<amount>".PaymentsFakeadds the amount to a module-levelcharges: Array<number>and returns"fake-<amount>". It prints nothing.checkout(amount)callschargeand printsreceipt <id>.
Run checkout(42) with the live layer. Then run checkout(42) and checkout(7) with the fake layer. Then print fake charges: <amounts joined by ", ">. Exact output:
stripe: charging 42
receipt stripe-42
receipt fake-42
receipt fake-7
fake charges: 42, 7import { Context, Effect, Layer } from "effect"
// TODO: class Payments with charge(amount: number): Effect<string>
// TODO: checkout(amount) prints "receipt <id>"
// TODO: PaymentsLive (prints "stripe: charging <amount>", returns "stripe-<amount>")
const charges: Array<number> = []
// TODO: PaymentsFake (records into charges, returns "fake-<amount>")
// TODO: run checkout(42) with PaymentsLive
// TODO: run checkout(42) and checkout(7) with PaymentsFake
console.log("fake charges:", charges.join(", "))
3. A three-level graph #
Connect a chain Config -> Database -> UserRepo and expose the correct services at the top.
Configholds{ env: string, dbUrl: string }.ConfigLiveprovides{ env: "prod", dbUrl: "postgres://prod" }withLayer.succeed.Databasehasquery(sql): Effect<Array<string>>.DatabaseLiveis aLayer.effect. It readsConfig, printsconnecting to <dbUrl>once when it builds, and answers every query with["Ada", "Lin"].UserRepohasname(id): Effect<string>.UserRepoLivereadsDatabase, queries"select name from users", and returns the row at indexid - 1.AppLivemust have the typeLayer.Layer<UserRepo | Config>. It exposesUserRepoandConfigbut hidesDatabase. Annotate it with that type so that the compiler checks it.
program prints user 1 is <name>, user 2 is <name>, then env: <env> from Config. Exact output:
connecting to postgres://prod
user 1 is Ada
user 2 is Lin
env: prodimport { Context, Effect, Layer } from "effect"
class Config extends Context.Service<Config, {
readonly env: string
readonly dbUrl: string
}>()("Config") {}
class Database extends Context.Service<Database, {
readonly query: (sql: string) => Effect.Effect<Array<string>>
}>()("Database") {}
class UserRepo extends Context.Service<UserRepo, {
readonly name: (id: number) => Effect.Effect<string>
}>()("UserRepo") {}
// TODO: ConfigLive, DatabaseLive (Layer.effect, prints "connecting to <dbUrl>"), UserRepoLive
// TODO: wire the graph. Config must stay visible, Database must be hidden.
// (Layer.empty is a placeholder and is a type error on purpose)
const AppLive: Layer.Layer<UserRepo | Config> = Layer.empty
const program = Effect.gen(function* () {
const repo = yield* UserRepo
const config = yield* Config
console.log("user 1 is", yield* repo.name(1))
console.log("user 2 is", yield* repo.name(2))
console.log("env:", config.env)
})
Effect.runSync(program.pipe(Effect.provide(AppLive)))
Recall #
Answer in your head first, then reveal. Come back to these tomorrow.
What does `R = never` at the top of a program guarantee? #
Every service that the program reads is provided. runSync and runPromise accept only R = never. A missing dependency is a compile error at the run call, not a runtime crash.
What is the type of `Effect.gen(function* () { const db = yield* Database; return yield* db.query("x") })`, if `query` returns `Effect<string>`? #
Effect<string, never, Database>. The yield* Database added Database to R. The success type comes from query.
Which function satisfies 1 requirement with a value that you already have? Which function provides a whole graph of services? #
Effect.provideService(Tag, impl) for 1 value. Effect.provide(layer) for a layer or a combined graph of layers.
What is the difference between `Layer.provide` and `Layer.provideMerge`? #
Both provide one layer to another. provide exposes only the service of the layer that receives, and hides the dependency. provideMerge exposes both. Use provide for internal details. Use provideMerge when code that uses the graph also needs the dependency.
When do you use `Layer.succeed`, and when do you use `Layer.effect`? #
Use Layer.succeed(Tag, impl) when the implementation is a plain value with no setup. Use Layer.effect(Tag, effect) when the build is work: read config, open a connection, or get other services with yield*.
2 layers in the same graph both depend on `ConfigLive`. How many times is it built? How do you force a second build? #
Once. Effect memoizes layers by identity in 1 graph. Wrap 1 occurrence in Layer.fresh(ConfigLive) to force a separate build, for example to isolate a test.
When is `Context.Reference` the correct choice instead of `Context.Service`? #
When the service has a good default, for example a log level or a feature flag. A Reference never appears in R. The program runs without it, and provideService can still override it.