The Elm architecture on Effect v4, for any renderer.
Documentation: this guide, the API in full and the examples running in the browser.
New to it? The tutorial builds an application step by step.
effect-tea-persistent brings the same architecture to long-lived entities on a server, whose state a database keeps. It is experimental, and not published yet.
You keep one state. A pure update changes it in response to messages. Effects are described as
data (commands) or listened to (subscriptions), never run inside update. The view draws a
model, made from the state, and sends messages.
flowchart LR
update --> state([state])
state --> makeModel
makeModel -->|model| view
view -->|message| update
state --> subscriptions[subscriptions, by key]
subscriptions -->|messages| update
update -->|commands| runCommand
runCommand -->|at most one message| update
What it adds to plain Elm:
update says what does not apply. It may reject a message that makes no sense in the current state,
and the run ignores it. So a save that moves the form to "saving" runs once, however fast the user taps.update throws defect on it, and the run dies
instead of hiding it. The app, around the run, decides what the user sees next: a crash screen, or the
program started again (see running).update. The testing tools check, for any state a test generates, that the
view sends only what update accepts, and leaves out only what the test says it withholds.Cmd is opaque: a test cannot see what update decided. Here a command
is a value of your own type, which runCommand turns into an Effect, so a test asserts it by value.bun add @fgaudo/effect-tea effect
effect (v4) is a peer dependency. The package ships JavaScript with declarations: use moduleResolution
nodenext or bundler, and TypeScript 5.9 or newer, as Effect 4 does. Under Bun, the bun export condition loads
the TypeScript sources instead.
A counter that cannot go below zero. The "renderer" here only logs, and clicks "increment" until the count
reaches 3; examples/counter draws the same program with snabbdom.
import { type Program, reject, run, type Update } from '@fgaudo/effect-tea'
import * as Effect from 'effect/Effect'
import * as Stream from 'effect/Stream'
type State = Readonly<{ count: number }>
type Message = 'Increment' | 'Decrement'
const update: Update<State, Message> = (message) => ({ count }) => {
if (message === 'Increment') return { state: { count: count + 1 } }
if (count === 0) return reject // nothing to decrement
return { state: { count: count - 1 } }
}
// At zero there is no `decrement`: the model's shape says so.
type Model =
| Readonly<{ _tag: 'AtZero'; count: number; increment: Message }>
| Readonly<{
_tag: 'Counting'
count: number
increment: Message
decrement: Message
}>
const counter: Program<{ state: State; model: Model; message: Message }> = {
init: { state: { count: 0 } },
update,
makeModel: ({ count }) =>
count > 0
? {
_tag: 'Counting',
count,
increment: 'Increment',
decrement: 'Decrement',
}
: { _tag: 'AtZero', count, increment: 'Increment' },
}
const main = run(counter, (frames) =>
frames.pipe(
Stream.tap(Effect.fnUntraced(function*({ model, dispatch }) {
yield* Effect.log(model.count)
if (model.count < 3) yield* dispatch(model.increment)
})),
Stream.take(4),
Stream.runDrain,
))
Effect.runFork(main) // logs 0, 1, 2, 3
What each piece is, and what else a program may have, is in the Guide.
examples/ holds complete programs, each with its tests, drawn with
snabbdom, and two of them with React too. Try one in the browser, or run it
with bun run build, then bun examples/<name>/index.html.
| Example | What it is | What it shows | Try it |
|---|---|---|---|
counter |
the quick start, with a view | applyAll, dispatchViolations, drive |
live |
counter-react |
the same program, in React | what changes with the renderer: the view, main.ts and the bridge, never the program |
live |
form |
a form that saves a name | an input the user owns while typing; a service; a failure as a message; a save only its completion ends; rehearse |
live |
form-react |
the form, in React | an uncontrolled input: defaultValue and key, never value |
live |
pages |
a clock and an About page | parent, optics, dispatchTo, events, subscribed, a page started from flags; simulate |
live |
list |
stopwatches by id | each, a subscription per running child, forEach; a test with a TestClock |
live |
timer |
the tutorial's application | timers by id, each ticking only while it runs, and a bell as a command; rehearse with hear |
live |
examples/snabbdom.ts and examples/react.ts are the small
bridges to the two renderers, worth copying for your own: renderInto for run, handlers, and
messagesSentBy for tests. Their tests, examples/snabbdom.test.ts and
examples/react.test.ts, run them in a DOM, with
happy-dom. They check how often a bridge draws, that a click reaches
the program, and that what the user types survives the frames drawn meanwhile.
A program is a plain object with up to five pieces:
| Piece | What it does | Needed |
|---|---|---|
init |
the first state and the commands to start with, or a function of its flags that makes them | always |
update |
(message) => (state) => the next state, commands and events, or reject |
always |
makeModel |
(state) => what the view draws, with the messages it sends |
always |
runCommand |
how each command happens, as an Effect | when it has commands |
subscriptions |
what it listens to in each state | when it listens |
Program<{ state; model; message }>, plus command, services, event,
external and flags when it has them.{ count } rather than count, so that any program can become a child as
it is (see composition). A program whose state is a number or a string does not compile.run hands your renderer a stream of frames ({ model, dispatch }) to
draw however it likes (see view).flags, as in Elm, e.g. a page that shows
the product it was opened for. Its init is then a function of them, init: ({ id }) => ({ state, commands }).
Whoever starts the program gives them: a parent to its child (see composition), and run to
the root (see running).update takes a message, then the state, and returns { state, commands?, events? }, or reject when the
message makes no sense in that state. Events are for a parent (see composition). On a message
that cannot arrive, whose arrival is a bug, it throws defect(reason), and the run dies (see
rejections and defects).
How to shape the state and its messages is in structuring a program.
makeModel(state) turns the state into what the view reads. Its shape says what the view shows: a case
for each state the view tells apart, each holding only what makes sense in it, the messages of its controls
included. In this example, Message is a Data.TaggedEnum and isBlank a helper of the program:
type Editor = Data.TaggedEnum<{
Saving: object
Blank: { changeName: (name: string) => Message }
Named: {
changeName: (name: string) => Message
save: Message
}
}>
const makeModel = (state: State): Editor => {
const changeName = (name: string) => Message.NameChanged({ name })
return state.save._tag === 'Saving'
? { _tag: 'Saving' }
: isBlank(state.name)
? { _tag: 'Blank', changeName }
: { _tag: 'Named', changeName, save: Message.SaveClicked() }
}
update reads it. A model that shows "Save" where update rejects
SaveClicked has a button that does nothing. The run does not check it; a test does, with the
testing tools, for any state it generates.What a model shows, and what a view may decide, is in structuring a program.
With its messages in the model, as here, a view sends only what the model holds. Export Message as a type
only, and no view can build one. A view may also build its messages itself, from a model of data alone. The
library does not mind, but a program reads more easily when it does one or the other.
A view is (model, dispatch) => Node, and dispatch takes the program's messages. Sending returns an
Effect<void> that needs no services and completes synchronously. It only hands the message to the run, which
handles it with its own services. A renderer whose handlers are plain callbacks, like snabbdom's, runs it at
once with Effect.runSync(dispatch(message)), or with a runtime of its own: either is right.
The library ships no renderer. A bridge to one does three things: it draws each frame, runs dispatch in the
handlers, and, for tests, finds what a view sends (messagesSentBy).
examples/snabbdom.ts and examples/react.ts are two, each small
enough to copy. counter-react and form-react draw the
programs of counter and form with the second. In React, a view stays a
function of its model, called by the bridge, never a component with state or hooks; React only draws what it
returns. React Native's bridge is written the same way.
A frame may be older than the user's last keystroke, so rewriting an input from every frame can eat what was just typed. Let one side own the text at a time:
update accepts in every state where the input exists.key, so that
the renderer creates a new input with the new text.examples/field.ts packages this, and form uses it. React and React
Native do the same with defaultValue and key, never value, as form-react does.
A command is data that says what update decided, usually a Data.TaggedEnum: Command.Save({ name }).
The program's runCommand turns it into an Effect that may produce one message back. Tests can then assert
the decision without running it:
expect(commands).toEqual([Command.Save({ name: 'Ada' })])
An execution cannot fail. Its type (Execution<Message, R>, an Effect<Option<Message>, never, R>) says
so. An expected failure becomes a message, as the success does, so that update decides what each means:
Effect.match, then Effect.asSome, at the end of a pipe or an Effect.fn. A defect (Effect.die, a
thrown error) ends the run. An effect that only acts on the world answers None: Effect.as(effect, Opt.none()).
const save = Effect.fn('Save')(
function*(name: string) {
const storage = yield* Storage
yield* storage.save(name)
},
Effect.match({
onSuccess: () => Message.Saved(),
onFailure: () => Message.SaveFailed(),
}),
Effect.asSome,
)
A command carries everything it needs. runCommand does not see the state. Its services are provided
around run.
A command has a scope of its own. What it acquires (Effect.acquireRelease, Effect.forkScoped) is
released when it ends, before its message reaches update, so run never asks for a Scope. What must
outlive a command is a service provided around run, or a subscription.
Commands cannot be cancelled. Once started, a command runs to its end and its message arrives, even if
the state has moved on. update then decides whether the answer still applies (see
rejections and defects). An operation that is a state only its completion leaves
has no outdated answer (see structuring a program). This keeps commands plain data,
and a save is never cut in half. Work that should stop is a subscription.
A command's services are its operations, and the layer around run their handlers. This is where a program
performs effects, in the algebraic sense, and where a handler lives. update performs none. What a child asks
of its parent is an event: data the parent handles after the child's step, never an operation the child resumes
from.
subscriptions(state) returns a HashMap of subscriptions by key: Effect Channels of messages. After
every change of state the keys are compared by value:
update has not seen.A subscription never ends in silence. Either it never ends (a clock: its end value is never), or it ends with
one last message, its end value (a download done). An ended subscription is not restarted until its key
goes away and comes back. The type says all of it: a Channel whose end value is a message, written with
Effect's own combinators, as the clock of pages and the stopwatch of
list are. A Stream becomes one with Stream.toChannel,
Channel.flattenArray and Channel.mapDone, which asks what its end means.
| Use a… | for |
|---|---|
| subscription | input tied to a state (a clock while a page is open, a scroll), or work that stops when the state no longer wants it (a download) |
| command | a one-off effect whose answer must arrive even if the state has moved on |
Work that should stop is a subscription. A download, or a search while the user types, can be one: keep its key in the state while the work is wanted. When the key goes away (the user cancels, or leaves the page) the subscription is interrupted, and what it holds is released. When the work ends by itself, its last message says so.
A subscription sees the state only through its key: what it depends on goes into the key, and a new value starts it anew. That is cheap for a timer whose interval changes. For something that should stay open while a value it uses changes, such as a connection whose filter the user picks, split it three ways:
run opens it when the program starts and closes it
when the run ends, so subscriptions and commands only use it;update decides that it changed.// The service: one connection for the whole run.
class Feed extends Context.Service<Feed, {
readonly items: Channel.Channel<Item, never, never>
readonly filter: (filter: string) => Effect.Effect<void>
}>()('Feed') {}
// While the feed is open, one subscription listens, whatever the filter.
subscriptions: (state) =>
state.feed._tag === 'Open'
? HashMap.make([
'feed',
Channel.unwrap(Effect.map(Effect.service(Feed), (feed) =>
Channel.map(feed.items, (item) => Message.Received({ item }))
)),
])
: HashMap.empty(),
// A new filter is a command: the connection stays open.
runCommand: (command) =>
Effect.flatMap(Effect.service(Feed), (feed) => feed.filter(command.filter)).pipe(
Effect.as(Opt.none()),
),
A message update cannot take in a state is one of two things:
reject |
defect(reason) |
|
|---|---|---|
| For a message… | that may arrive, and does not apply in this state | that cannot arrive: a bug |
| For example | a tap on a frame the state has moved past; the answer of a command for a row since replaced | a completion outside the state only its completion leaves; a subscription's message in a state without its key |
| What the run does | ignores it, logs it at the debug level, tells onIgnored, and keeps the state |
dies with a ProgramDefect that says where, whose cause is an InvariantDefect holding the reason |
reject changes nothing. A second click, or an answer that arrives once the state has moved on, leaves the
state as it was.
defect makes a bug loud. form throws it on a save that completes while nothing
is saving, where a rejection would hide the bug. The run dies as it dies with any defect (an Effect.die, a
thrown error: broken code). The InvariantDefect lets a crash report tell a broken rule of the program from
any other error. defect is an expression, for a branch of a ternary or a Match. It is for the synchronous
functions (update, onEvent, makeModel, subscriptions); inside an Effect, Effect.die stays.
"Cannot arrive" is a promise of the program's state, kept by the run. The state says that only a completion leaves an operation, and a command answers once. The state stops listing a key, and the run drops what that subscription sent. Nothing else makes that promise:
onEvent cannot rule it out by its own state either. A handler answers with a
step, and its type has no reject: one that does not apply answers { state }, as
pages does when About is asked for while it is open. A completion promises
something of the child's state, not of its parent's. An event raised for one rules out a parent's state only
where the parent's own state promises it too.To tell them apart, ask whether the message could reach that state while the program's code and the library's
are right. If a fast finger, a slow frame or another device is enough, it is a reject. If only a bug can
bring it there, it is a defect.
Whether the view sends only what applies, and all of it, is for the tests (see testing).
A child is any program. A parent declares its children with parent, and the library does the routing:
const { child, compose } = parent<{ state: State }>()
const state = Optic.id<State>()
export const app = compose({
init: (children) => ({
state: { clock: children.Clock.init.state, maybeAbout: Opt.none() },
commands: children.Clock.init.commands,
}),
children: {
Clock: child(Clock.clock, {
at: state.key('clock'),
// Hidden under About, the clock keeps its state but stops ticking.
subscribed: (state) =>
Opt.isNone(state.maybeAbout) ? Opt.some(state.clock) : Opt.none(),
}),
// Closed, About is not there: the optic focuses nothing.
About: child(About.about, { at: state.key('maybeAbout').compose(Optic.some()) }),
},
onEvent: {
// About starts from the time the Clock showed, its flags; a double click opens it once.
Clock: (event, children) => (state) => {
if (Opt.isSome(state.maybeAbout)) return { state }
const about = children.About.init({ openedAt: event.at })
return {
state: { ...state, maybeAbout: Opt.some(about.state) },
commands: about.commands,
}
},
About: () => (state) => ({ state: { ...state, maybeAbout: Opt.none() } }),
},
// Only the page on top is drawn.
makeModel: (state, children) => ({
page: Opt.isNone(state.maybeAbout)
? { _tag: 'Clock' as const, model: children.Clock(state) }
: { _tag: 'About' as const, model: children.About(state) },
}),
})
at (Effect's Optic: Optic.id<State>() .key('clock'), with .compose(Optic.some()) for a child that may not be there), or the two functions an
optic is, get and set. Where the optic focuses nothing, or get returns None, the child is not there.subscribed (by default, whenever it is there).init is a function of children, so that a child there from the start is
started like any other. children.Clock.init holds its state and its commands, lifted; reading
Clock.clock.init.state would leave its commands behind. A child with flags is started from them:
children.About.init({ openedAt }), and init(id, flags) for children by id.parent<{ state: State; flags: Flags }>(). Its init then returns the function that starts it:
init: (children) => (flags) => ({ state, commands }).makeModel, from its state and its children's models.update, runCommand (required exactly when
it has commands of its own), subscriptions. Its update takes children too, and may reject its own
messages as any update does.What the library derives from it:
{ _tag: <child>, message } for each child. MessageOf<typeof app>
names them. An own message that takes a child's name does not compile.runCommand.[<child>, key] ([<child>, id, key] for
children by id). HashMap.has(subscriptionsOf(app)(state), ['Clock', 'ticks']) tells a test whether the
Clock ticks.children.About(state) in makeModel is the child's model. It is the same object
while the child's state is the same object, so a renderer can skip redrawing it (see thunk in
pages). A number or a string has no identity to keep a model by: this is why a
program's state is always an object. Asking for the model of a child that is not there is a defect (a
CompositionDefect).A parent and its children talk only through events and external messages.
onEvent has one handler
per child that has events, and is required exactly when one has, so that no event goes unhandled. An event
the parent has nothing to say about, and its own parent handles, it raises as its own:
onEvent: { Products: raise }, or (_, event) => raise(event) for children by id. Its Event then includes
the child's, or this does not compile.children.<child>.update, in its own update and
onEvent: one of the messages the child's type declares as external (e.g. a back button). The parent
decides when to send, and the child's type says it takes the message. The child answers in every state, with
its state or with an event, never with reject. A rejection there is a defect (a CompositionDefect, naming
the child), as is sending to a child that is not there.A back button closes what the page has open, or the page asks to be left:
// In the page: back always means something.
case 'BackPressed':
return state.menu._tag === 'Open'
? { state: { ...state, menu: { _tag: 'Closed' } } }
: { state, events: [{ _tag: 'LeaveRequested' }] }
// In its parent: what the page asks for, in the same step.
update: (_: BackPressed, children) => children.Page.update({ _tag: 'BackPressed' }),
onEvent: { Page: () => (state) => ({ state: leavePage(state) }) },
children.Page.update returns the parent's step, as a function of its state. To take a step of its own before
a child's, a parent adds them up:
andThen: andThen(leave(state), children.Home.update({ _tag: 'ProductGone', at })). The result has the
second step's state, and both steps' commands and events, in order. Taking a step's .state by hand to make
the next one drops its commands and its events.forEach is the andThen of many: one step for each item, each made from the state the previous one
left, e.g. a message to every child by id,
forEach({ state }, state.rows, ({ id }) => children.Row.update(id, Cleared())).Both take the step last as well, for a chain written with Effect's pipe:
pipe({ state }, andThen(leave), forEach(ids, clear)).
A child's view sends the child's own messages, through a dispatch of its own: dispatchTo(dispatch, 'Clock')
wraps them as the parent receives them. It is the same function in every frame, so a view memoized on its
model and its dispatch is drawn again only when its model changes:
const view: View<Model, Message, VNode> = (model, dispatch) =>
model.page._tag === 'Clock'
? thunk('main', 'clock', clockView, [
model.page.model,
dispatchTo(dispatch, 'Clock'),
])
: thunk('main', 'about', aboutView, [
model.page.model,
dispatchTo(dispatch, 'About'),
])
A lambda that wraps the message, (message) => dispatch({ _tag: 'Clock', message }), sends the same messages,
but it is a new function in every frame: a view memoized on it is drawn every time. Use dispatchTo for every
child, memoized or not, and there is nothing to remember.
A grandchild's dispatch is made from its parent's: dispatchTo(dispatchTo(dispatch, 'Home'), 'Products'). A
view usually writes it in two steps, each view taking the dispatch its parent gave it.
For children by id, such as the rows of a list, use each instead of child:
get and set take the id (or at is a function of it);subscribed lists the rows that listen;children.Row(state, id) is a row's model, and dispatchTo(dispatch, 'Row', id) its dispatch.Ids are compared by value, with Equal, wherever they go (subscription keys, dispatches): strings, numbers, or
tuples and structs of them. See list.
Mind ids that come back. A command's answer goes to the id it was started for, to whichever row has that id
when it arrives. If a row is removed and another is added with its id, the new row may get the old row's
answer. Use ids that are never reused, or put in the message which row it is for, so that the new row's
update rejects the old answer. Subscriptions do not need this: a row that is removed stops them.
The sections above say what the library does. These rules say how to write a program so that what it promises holds, and its tests can check it. The library does not enforce them; the examples follow them.
update. "Can the user save now?" is answered once, by rejecting. The view never
repeats the check.MenuOpened and MenuClosed, not MenuToggled. An intent sent twice does
what it did once; a toggle undoes itself.SaveClicked the form is saving and rejects
a second SaveClicked, so a double click saves once.form
is. When its answer arrives, the state cannot have moved on, and a completion anywhere else is a defect.Idle | Saving | Failed), holding only what exists in it. Flags that combine
(isSaving, hasFailed) allow combinations that cannot be; a union makes them fail to compile, and update
matches on the case.makeModel. A value that depends on
time is computed there too, from a now that a subscription keeps in the state, as the clock of
pages does.update accepts, never more. What it leaves out on purpose, a test lists as withheld
(see testing); anything else left out is a control missing from the screen.items.length === 0), combines no fields, and applies no rule and
no fallback. What a view would decide, makeModel decides, as a case named for the state the screen tells
apart (Empty | Listed, Loading | Loaded), never a boolean. An Option is such a case only when
"missing" is all the screen tells apart.update says (see
view), so a value update rejects would leave on screen a text the state does not have. Store what
is typed, and make its validity model data: show "Save" only for a name that is not blank.A program lives in one module until it is too large. Then it splits into children, each a program of its own:
messages, state and update modules would import each other in cycles.run(program, render, options?) starts the program and hands render a stream of frames. The run ends when
the renderer ends. Only a program without events runs: there is no parent to handle them. A renderer slower
than the program only gets the latest frame, so a renderer that waits for the next animation frame before
taking another draws at most once per screen refresh, as examples/snabbdom.ts does.
Options:
flags: what the program starts from, required exactly when it has them, e.g. the product a
notification opened the app for.describeMessage, describeCommand: how messages and commands appear in the logs. By default only
their tags, never their data: Home › Products › Deleted. They are called only while debug logs are enabled
(References.MinimumLogLevel), so they cost nothing otherwise. The debug logs of subscriptions show their
keys, which may hold data (an id, a search's text).onApplied: hears every accepted message with where it came from (origin: the view, a command, or a
subscription with its key), the state before and after it, and the commands it started, for devtools or
session recording.onIgnored: hears every message update rejected, with where it came from and the state it did not
apply in. It only watches: the message stays ignored. For devtools, or to count the taps that did nothing.The run logs only at the debug level: each message, a rejected one, and subscriptions starting, stopping and
ending. Effect's default minimum level, Info, hides them all.
A program starts where its init says. To start it elsewhere, from a state saved by onApplied or kept across
a reload in development, give run the program with another first step: { ...program, init: { state } }
(its commands, if any, with it). A session recorded with onApplied plays again the same way, with applyAll
in a test.
A defect anywhere ends the run, which dies with a ProgramDefect:
cause is the defect itself, an InvariantDefect for one thrown with defect;where says where it happened, which only the run knows: 'update' (onEvent included), with the message
it was given as on, by its tags; 'subscriptions'; 'subscription', with its key; 'command';
'render' (the view and makeModel included); or one of the options, by name.The run never recovers from it, and does not log it: reporting it, and what the user sees next, are the app's,
around the run. The screen keeps the last frame: show a crash screen, or run the program again. In a browser,
start it with BrowserRuntime.runMain from @effect/platform-browser. An interruption, such as the page
closing, ends the run too, and is no crash:
BrowserRuntime.runMain(
run(app, render, options).pipe(
Effect.tapCause((cause) =>
Cause.hasInterruptsOnly(cause) ? Effect.void : reportCrash(cause)
),
),
)
What you can rely on:
update rejects is ignored: the state stays as it was, however
many messages came in between.update afterwards,
even if the same key comes back later.ProgramDefect that says where it happened.What is left to you:
update decides whether it still applies (see commands, and ids that come back in
children by id).update accepts, all of it but what it withholds on purpose. The run does not check
it: dispatchViolations does, in tests (see testing).update, makeModel and subscriptions are pure, and states are never mutated. The library reuses a
child's model, and its subscriptions, while its state is the same object.The library's tests check each guarantee. src/interleaving.test.ts checks
them under random orders of clicks, command answers and subscription messages.
Most of a program is pure, so most tests need no runtime.
A program's tests follow its shape:
update answers every message with a step or reject, the same each time,
that the view sends what update accepts (dispatchViolations), and what the program listens to
(subscriptionsOf).isRejected, never as "the state did not change", which a step that keeps the state would
pass too.expect(() => update(message)(state)).toThrow(InvariantDefect).test('counts up and down', () => {
expect(
applyAll(update)(['Increment', 'Increment', 'Decrement'], { count: 0 })
.state,
)
.toEqual({ count: 1 })
})
// For any count, the real view and `update` agree both ways.
test('the view sends what update accepts, all of it', () => {
fc.assert(fc.property(fc.nat(1_000), (count) => {
const sent = messagesSentBy<Model, Message>(
view,
counter.makeModel({ count }),
)
const check = dispatchViolations(update, { repeatable: () => true })
expect(check({ count }, sent, ['Increment', 'Decrement'])).toEqual([])
}))
})
program.makeModel(state) makes a model as run does, so a property test can draw the view in any state,
and subscriptionsOf(program)(state) says what the program listens to there.messagesSentBy is not in the package: each bridge writes its own
(examples/snabbdom.ts, examples/react.ts), since only the
renderer knows what a handler is. One for a renderer of your own takes the view, a model and the text typed,
and:
dispatchTo wraps that one, so a child's messages are recorded as the root of
the program receives them;From @fgaudo/effect-tea/testing:
| Tool | Use it to test |
|---|---|
applyAll |
update's rules: apply messages, check the state, commands and events |
dispatchViolations |
that the view sends what update accepts, all of it, and a double tap acts once |
simulate |
a flow with its commands answered at once, without a runtime |
rehearse |
a user's scenario without a runtime, the test deciding when each command runs |
drive |
a user's scenario on the running program, subscriptions included |
Where the test decides when each message arrives, a rejected message fails it if the test, or the view through
it, sent it. What arrives by itself is ignored, as when the program runs. In drive time is real, so every
rejection is ignored:
| Rejected… | a message the test or the view sends | a command's answer | a subscription's message |
|---|---|---|---|
applyAll |
fails the test | (commands do not run) | (the test sends it: it fails) |
simulate |
fails the test | ignored, in ignored |
(the test sends it: it fails) |
rehearse |
fails the test | ignored, in ignored |
ignored, in ignored |
drive, and the program as it runs |
ignored | ignored | ignored |
A tap on a frame that the state has since moved past is a message applied to a later state: test what it does
there with applyAll, or with update itself.
applyAll takes only the test's messages, and fails when update rejects one of them.
dispatchViolations checks both ways:
Rejected), and act once when sent twice (ActsTwice): sent
again from the same frame, it is rejected or changes nothing, the same state by Equal with no commands and
no events. Keep states data, since a value without structural equality compares by reference. Actions meant
to repeat (a "+1") are marked repeatable;candidates the test lists (what a user could ask for, with the text typed into each input), each one
update accepts must be among those sent (Missing), except those withheld on purpose, e.g. typing while
a save is in progress.Without candidates, it checks only what the view sends.
simulate takes the test's messages and those a subscription would send among them, and fails when
update rejects one of them. The command answers that update rejected are listed in ignored: assert it
when one being dropped would be a bug.
rehearse acts through what the current model shows, as drive does, but nothing runs by itself. Each
command waits until the test runs it (run, runAll), through the program's runCommand with the services
provided. A subscription's message is one the test says was heard (hear), or the first one the subscription
itself sends when the test runs it (hearNext), only while its key is in the state. So a test says what the
user does while a save is under way, and when its answer arrives:
rehearse(
form,
Effect.fnUntraced(function*(rehearsal) {
yield* rehearsal.send(({ editor }) =>
editor._tag === 'Saving'
? Opt.none()
: Opt.some(editor.changeName('Ada'))
)
yield* rehearsal.send(({ editor }) =>
editor._tag === 'Named' ? Opt.some(editor.save) : Opt.none()
)
expect((yield* rehearsal.model).editor._tag).toBe('Saving')
yield* rehearsal.run((command) => command._tag === 'Save')
}),
)
It fails when send finds nothing to send on the model, when update rejects what it sends (a control the
model shows and its own state rejects), and when a command is still pending at the end: its answer would
still arrive. A command's or a subscription's message that no longer applies is ignored, as when the program
runs, and listed in ignored. It starts from the program's init, or from the step given as from; a
program with flags needs either from or its flags.
drive sends what the frames show, through the run's dispatch, and fails on a wait longer than
patience (2 seconds). Its time is real: a frame may be past by the time its message arrives. So, as when
the program runs, a rejected message is ignored, and a scenario checks what it expects to see. A program with
flags gets them as run does, in the options. Its commands run as soon as they start: to hold one, give the
program a service of the test's that waits on a Deferred the scenario completes.
Each export is documented in full in the documentation. From
@fgaudo/effect-tea:
| Export | What it is |
|---|---|
Program<{ state; model; message; command?; … }> |
init, update, runCommand (with commands), subscriptions (optional), makeModel |
StateOf, ModelOf, MessageOf, CommandOf, … |
a program's types, read back from it (EventOf, ExternalOf, FlagsOf, ServicesOf) |
parent |
parent<{ state; … }>(): child, each and compose, to make a parent of children |
Update, Transition, Step |
(message) => (state) => Step or Rejected; a Step is { state, commands?, events? } |
reject, Rejected, isRejected |
the rejection update returns, and its check |
defect, InvariantDefect |
defect(reason) throws an InvariantDefect: a message that cannot arrive, a defect |
andThen, forEach |
a step and the next made from its state, or one per item; dual |
raise |
(event) => (state) => Step: a child's event raised as the parent's own |
Execution<Message, R>, RunCommand |
Effect<Option<Message>, never, R>, how a command happens; (command) => Execution |
Subscription |
a Channel of messages that never ends, or ends with one last message; kept by key |
Subscriptions |
(state) => HashMap<K, Subscription<Message>>, reconciled by key |
subscriptionsOf |
what a program listens to in a state, by key, as run reconciles it |
View, Dispatch |
(model, dispatch) => Node; (message) => Effect<void>, the same in every frame |
dispatchTo |
a child's dispatch, for its view: wraps its messages as the parent receives them |
run, Frame, RunOptions |
run a program with a renderer; what the renderer draws; how the program runs |
Applied, Ignored, Origin |
what onApplied and onIgnored hear: a message, where it came from, the states around it |
ProgramDefect, DefectSite |
the defect a run dies with: its cause, where it happened, update's message, a subscription's key |
CompositionDefect |
a parent breaking what it declared of a child, which the run then dies with |
From @fgaudo/effect-tea/testing: applyAll, simulate, Simulation, rehearse, Rehearsal,
RehearseOptions, drive, DriveOptions, Driver, dispatchViolations, DispatchViolation.
bun run typecheck, bun run lint and bun run test check the package and the examples; bun run build
compiles it into dist/, and bun run smoke checks the packed package as Node and TypeScript consumers get it.
Changes are in CHANGELOG.md, open problems and possible improvements in
ROADMAP.md. The rules for contributors and coding agents are in
AGENTS.md, and the procedures (changing the API, adding an example, upgrading Effect, releasing and
building the documentation site) in .agents/skills/.
MIT © Francesco Gaudenzi