Getting started
Install
Affect is not on npm. Clone the repository, run pnpm build, and import from dist/. There is nothing to install, which is consistent.
Your first affect
An affect is a value that is affected by other values. The simplest affect is a source: a value affected by nothing except Affect.set.
import { Affect } from "affect"
const count = Affect.succeed(0)
count has the type Affect<number>, which is short for Affect<number, never, never>: currently a number, in no fault, affected by nothing that has to be provided.
Deriving
Derived affects follow their sources.
const doubled = Affect.map(count, (n) => n * 2)
doubled is not computed here. Nothing is computed until something is observing. When something is, doubled re-evaluates every time count is set, and only then.
Observing
const stop = Affect.observe(doubled, {
onSuccess: (n) => console.log("doubled is", n)
})
// doubled is 0
Affect.set(count, 1)
// doubled is 2
Affect.set(count, 1)
// (nothing: the value did not change)
stop()
Affect.set(count, 2)
// (nothing: nobody is watching)
observe calls the handler immediately with the current value and again every time it changes. It returns a function that stops the observation.
There is no run. An affect is not something that runs. If you need the current value once and do not intend to keep watching, Affect.runSync returns a snapshot, with the understanding that it is stale the moment it returns.
Generators
Affect.gen lets you derive a value with ordinary control flow. Each yield* subscribes to an affect and evaluates to its current value.
const first = Affect.succeed("Ada")
const last = Affect.succeed("Lovelace")
const full = Affect.gen(function* () {
const a = yield* first
const b = yield* last
return `${a} ${b}`
})
The body re-runs whenever any yielded affect changes. It subscribes only to what it actually yielded on the last run, so a branch that was not taken is not a dependency.
const useNickname = Affect.succeed(false)
const nickname = Affect.succeed("Countess")
const displayName = Affect.gen(function* () {
if (yield* useNickname) return yield* nickname
return yield* full
})
While useNickname is false, changing nickname does nothing to displayName. Flip useNickname and the body re-runs, this time subscribing to nickname and letting go of full.
Combining
const both = Affect.all([first, last]) // Affect<[string, string]>
const record = Affect.all({ first, last }) // Affect<{ first: string; last: string }>
const zipped = Affect.zip(first, last) // Affect<[string, string]>
Writing
Affect.set and Affect.update write to a source. They are not affects. They return void and take effect synchronously; every observer downstream is notified before set returns.
Affect.set(count, 5)
Affect.update(count, (n) => n + 1)
To notify observers once after several writes, wrap them in Affect.batch.
Affect.batch(() => {
Affect.set(first, "Augusta")
Affect.set(last, "King")
})
// observers of `full` see one change: "Augusta King"
Only sources can be set. Attempting to set a derived value is a defect.
Next
Faults
The second type parameter of Affect<A, E, R> is the fault channel.
A fault is a state a value can currently be in. It is not an error in the sense of something that happened. It is a description of why there is no value right now.
Declaring faults
import { Data } from "affect"
class Negative extends Data.TaggedError("Negative")<{ readonly value: number }> {}
A Data.TaggedError is an Error, has a _tag, and compares structurally. Two Negative faults with the same value are the same fault, and a value that goes from one to the other has not changed.
Entering a fault
Inside Affect.gen, yielding a fault puts the derived value in that fault and stops the body there.
const balance = Affect.succeed(100)
const checked = Affect.gen(function* () {
const value = yield* balance
if (value < 0) return yield* new Negative({ value })
return value
})
// Affect<number, Negative>
Without a generator, Affect.filterOrFail does the same thing:
const checked = Affect.filterOrFail(
balance,
(n) => n >= 0,
(n) => new Negative({ value: n })
)
Affect.fail(fault) creates a source that starts in a fault. It is still a source; Affect.set recovers it.
Leaving a fault
You do not leave a fault. The fault leaves.
checked is in the Negative fault exactly as long as balance is negative. When balance is set to something else, checked re-evaluates, the condition no longer holds, and the value is back. Nothing needs to be retried, because nothing was attempted.
Handling faults
catchTag supplies a replacement value for as long as the fault is present.
const display = Affect.catchTag(checked, "Negative", (e) =>
Affect.succeed(`overdrawn by ${-e.value}`)
)
// Affect<number | string, never>
The handler returns an affect, so the replacement can be live too. When the fault clears, display resumes following checked.
catchTags handles several tags at once. catchAll handles every fault. orElse and orElseSucceed substitute a fallback for any fault. mapError changes the fault without handling it.
Observing faults
An observer sees faults as well as values.
Affect.observe(checked, {
onSuccess: (n) => console.log("balance", n),
onFailure: (e) => console.log("faulted:", e._tag)
})
If onFailure is omitted, the observer stays subscribed and waits. A fault is not a reason to stop watching.
Affect.runPromise resolves with the first successful value. If the value is currently faulted, the promise waits for the fault to clear. It does not reject on a fault. A promise that rejected on a fault would be describing something that ended, and nothing has.
Defects
A defect is not a fault. Defects are bugs: an exception thrown inside map, a service that was never provided, an attempt to set a derived value. They cannot be caught with catchTag or catchAll, do not appear in E, and reach the observer through onDie. If there is no onDie, the defect is thrown.
Affect.try turns a throwing thunk into a fault, which is the usual way to keep exceptions in the fault channel.
Services and layers
The third type parameter of Affect<A, E, R> is what the value is affected by.
In Effect, R lists the services a computation needs in order to run. In Affect, R lists the sources a value needs in order to be. Both are provided with a Layer. Only one of them can change under you.
Declaring a service
import { Context } from "affect"
class Clock extends Context.Tag("Clock")<Clock, { readonly now: number }>() {}
A tag is an affect. Yielding it reads the current service.
const stamp = Affect.gen(function* () {
const clock = yield* Clock
return `t=${clock.now}`
})
// Affect<string, never, Clock>
The Clock in R means: this value cannot be observed until someone says what Clock is.
Providing a constant
import { Layer } from "affect"
const ClockTest = Layer.succeed(Clock, { now: 0 })
const runnable = Affect.provide(stamp, ClockTest)
// Affect<string, never, never>
Providing a live source
Layer.affect provides a service that is itself an affect. When the affect changes, everything that reads the service follows.
const now = Affect.succeed(0)
const ClockLive = Layer.affect(Clock, Affect.map(now, (n) => ({ now: n })))
Affect.observe(Affect.provide(stamp, ClockLive), { onSuccess: console.log })
// t=0
Affect.set(now, 1)
// t=1
stamp did not know about now. It asked for a Clock and was given one that moves.
Composing layers
class Theme extends Context.Tag("Theme")<Theme, { readonly mode: "light" | "dark" }>() {}
const ThemeLive = Layer.succeed(Theme, { mode: "dark" })
const AppLive = Layer.merge(ClockLive, ThemeLive)
// Layer<Clock | Theme>
Layers can depend on other layers. Layer.provide feeds one layer's output into another's requirements.
class Greeter extends Context.Tag("Greeter")<Greeter, { readonly greet: (name: string) => string }>() {}
const GreeterLive = Layer.affect(
Greeter,
Affect.gen(function* () {
const clock = yield* Clock
return { greet: (name: string) => `hello ${name}, it is ${clock.now}` }
})
)
// Layer<Greeter, never, Clock>
const GreeterWithClock = Layer.provide(GreeterLive, ClockLive)
// Layer<Greeter>
GreeterLive reads Clock. Since ClockLive is live, Greeter is live, and anything that reads Greeter re-evaluates when now changes. Requirements do not just get satisfied; they stay satisfied.
Memoization
A layer is built once per place it is provided. The same affect provided with two different layers produces two independent values, each following its own sources. A tag read inside a provided affect resolves against the nearest enclosing provide.
Missing services
Observing an affect whose R is not never is a type error. If the types are bypassed, reading a missing service is a defect with the message Service not found: Clock.
Migrating from Effect
Step 1
Change the import.
- import { Effect, Context, Layer, Data, pipe } from "effect"
+ import { Affect, Context, Layer, Data, pipe } from "affect"
Step 2
Rename Effect to Affect throughout.
- const program = Effect.gen(function* () {
+ const program = Affect.gen(function* () {
Step 3
There is no step 3. Your program now typechecks and does something else.
What changed
Below is the same program under both libraries.
class Config extends Context.Tag("Config")<Config, { readonly limit: number }>() {}
const program = X.gen(function* () {
const config = yield* Config
const items = yield* fetchItems
if (items.length > config.limit) return yield* new TooMany({ count: items.length })
return items
})
With X = Effect, program is a recipe. Running it reads Config, runs fetchItems, and either produces the items or fails with TooMany. Then it is over. Run it again for a new answer.
With X = Affect, program is a standing claim. Observing it subscribes to Config and fetchItems. It is the items, until there are too many, at which point it is TooMany, until there are not, at which point it is the items again. It is never over until you stop looking.
Correspondence
| Effect | Affect | Note |
|---|
Effect.succeed(a) | Affect.succeed(a) | Creates a source instead of a constant. |
Effect.fail(e) | Affect.fail(e) | Creates a source that starts in a fault. |
Effect.gen | Affect.gen | Body re-runs on change. |
yield* | yield* | Subscribes instead of awaiting. |
Effect.map / flatMap | Affect.map / flatMap | Same signatures. flatMap swaps the inner value when the outer changes. |
Effect.catchTag | Affect.catchTag | Handler is active while the fault is present, then steps aside. |
Effect.retry | | Not needed. Faults clear on their own. |
Effect.runPromise | Affect.runPromise | Resolves the first success. Waits through faults. |
Effect.runSync | Affect.runSync | Returns a snapshot. Discouraged. |
Effect.runFork | Affect.observe | Returns a function that stops observing. |
Ref.make / Ref.set | Affect.succeed / Affect.set | Sources are refs that everything can see. |
Layer.succeed | Layer.succeed | Same. |
Layer.effect | Layer.affect | The service is live. |
Effect.provide | Affect.provide | Same signature. |
Data.TaggedError | Data.TaggedError | Same, including structural equality and yield*. |
Effect.log | Affect.log | Logs each time the enclosing value evaluates. |
Effect.sleep, Effect.fork, Fiber, Schedule, Stream | | Not present. Time is a source; provide it. |
Things that stop mattering
- Retries. A fault is a condition, not an event. When the condition changes, the value comes back.
- Interruption. Stop observing.
- Fibers. There is nothing to run concurrently. Every observer sees a consistent graph.
- Ordering. Sources are set; everything downstream follows, once, in dependency order.
Things that start mattering
- Identity.
Affect.succeed(1) twice is two sources. Create sources once and share them. - Equality. A value that is set to an equal value does not propagate.
Data classes compare structurally; everything else compares with Object.is. - Lifetime. An observed affect stays subscribed until its
observe handle is called. Call it.
Migrating back
Change the import.
API reference
All combinators are dual. Affect.map(self, f) and Affect.map(f)(self) are equivalent, and every affect has a .pipe method.
Affect
Constructors
| Function | Type | Description |
|---|
succeed(a) | Affect<A> | A source currently holding a. |
fail(e) | Affect<never, E> | A source currently in fault e. |
die(defect) | Affect<never> | A defective value. |
void | Affect<void> | A source holding undefined. |
sync(thunk) | Affect<A> | Evaluated on first observation, never again. |
suspend(() => affect) | Affect<A, E, R> | Defers construction to observation. |
try(thunk) / try({ try, catch }) | Affect<A, E> | A throw becomes a fault. |
gen(function* () {}) | Affect<A, E, R> | Derives with a generator. Re-runs on change. |
Writing
| Function | Description |
|---|
set(source, a) | Writes a. Synchronous. Only sources. |
update(source, f) | Writes f(current). No-op while faulted. |
batch(() => {}) | Notifies observers once after all writes inside. |
Mapping and sequencing
| Function | Description |
|---|
map(self, f) | Derives with f. |
as(self, b) / asVoid(self) | Replaces the value. |
mapError(self, f) | Transforms the fault. |
flatMap(self, f) | Follows the affect returned by f, swapping it when self changes. |
andThen(self, x) | flatMap that also accepts plain values and affects. |
tap(self, f) | Runs f on every change without altering the value. |
all([...]) / all({...}) | Combines a tuple or struct. Faults fast. |
zip(self, that) / zipWith(self, that, f) | Combines two. |
Faults
| Function | Description |
|---|
catchTag(self, tag, f) | Handles one tagged fault while present. |
catchTags(self, { Tag: f }) | Handles several. |
catchAll(self, f) | Handles every fault. |
orElse(self, () => that) | Substitutes on any fault. |
orElseSucceed(self, () => a) | Substitutes a plain value. |
filterOrFail(self, predicate, orFailWith) | Faults while the predicate does not hold. |
exit(self) | Exposes the current Exit as the value. |
Context
| Function | Description |
|---|
provide(self, layer) / provide(self, context) | Supplies sources. |
provideService(self, tag, service) | Supplies one constant service. |
Observing
| Function | Description |
|---|
observe(self, handler) | Starts observing. Returns a stop function. |
runSyncExit(self) | The current Exit, once. |
runSync(self) | The current value, once. Throws the fault if faulted. |
runPromise(self) | Resolves the first success. Waits through faults. Rejects on defects. |
Logging
| Function | Description |
|---|
log(...args) | Logs each time the enclosing value evaluates. |
Context
| Function | Description |
|---|
Tag(id)<Self, Shape>() | Declares a service. The tag is an Affect<Shape, never, Self>. |
empty() / make(tag, service) / add(ctx, tag, service) / merge(a, b) | Builds a context by hand. |
get(ctx, tag) | Reads a service's current value. |
Layer
| Function | Description |
|---|
succeed(tag, service) | A constant service. |
sync(tag, () => service) | A constant service, built on first use. |
affect(tag, affect) | A live service. |
empty | Provides nothing. |
merge(a, b) / mergeAll(...layers) | Combines layers. |
provide(self, that) | Feeds that into self's requirements. |
provideMerge(self, that) | Same, and also exposes that. |
Data
| Function | Description |
|---|
TaggedError(tag)<Fields> | A fault class. Structural equality. Yieldable. |
Error<Fields> | An untagged fault class. |
Class<Fields> / TaggedClass(tag)<Fields> | Plain data with structural equality. |
struct(obj) | A plain object with structural equality. |
Exit
Exit<A, E> is Success<A> | Failure<E> | Die. Constructors succeed, fail, die; refinements isSuccess, isFailure, isDie; and match.
Console
Console.log, Console.error, Console.warn. log re-runs with its enclosing value; error and warn run once.
FAQ
Is this Effect?
No.
Is it compatible with Effect?
The API is. The behavior is not. Code written for one will typecheck against the other after a rename, which is either a feature or the whole problem, depending on which one you meant to install.
Which one should I use?
If your program is a thing that happens, Effect. If your program is a thing that is true, Affect.
Why does gen run my body more than once?
Because something it yielded changed. That is what gen is for. If you want a body that runs once, do not yield anything that changes, or use Effect.
Why did my fault go away by itself?
Because whatever caused it changed. Faults describe a current condition. When the condition stops holding, the fault stops holding. If you want a fault that persists after its cause is gone, you want an error, and errors are in Effect.
Where is retry?
Nowhere. There is nothing to retry. When the sources change, the value re-evaluates. If you want to try again without changing anything, the answer will be the same.
Where is sleep?
Time is a source. Create one with Affect.succeed(Date.now()), set it from wherever you keep your time, and provide it through a Layer. Affect does not know what time it is and would rather you told it.
Where are fibers?
There is one graph. Every observer sees it in a consistent state. Concurrency is a property of things that run.
Why does runPromise not reject on a fault?
A rejected promise is a computation that ended badly. A fault is a value that is currently unavailable. The promise waits. If the fault never clears, the promise never settles, which is an accurate account of the situation.
Can I use both libraries in the same project?
Yes. Import them under their own names. Do not alias one to the other.
Is the name a joke?
Affect is the noun for a displayed emotional state and the verb for influencing something. Both are apt.