This is the Effect App library repository, focusing on functional programming patterns and effect systems in TypeScript, wrapping and extending the Effect library.
- The git base branch is
main - Use
pnpmas the package manager
Always open a PR for agent work that changes the repo — do not leave finished work only on a local branch.
- Draft early: open a draft PR as soon as there is a meaningful commit (or when starting multi-step work that will land), so review/CI can track progress while the rest of the work continues. Pushes to a draft run the same gate as any other push — the draft is about review status, not about skipping checks.
- Keep the PR current: push commits as you go; update the PR body if scope shifts.
- Ready when done: publish with
pnpm pr:ready, or with plaingh pr ready— both run the ship gate first and undraft only if it passes. Do not hand-run the checks beforehand and do not leave a finished, validated change as draft. - Base: target
mainunless the work is explicitly stacked on another branch.
.githooks/pre-push is the agent ship gate. It is a no-op for humans and
fires only for coding agents (GROK_AGENT / T3_AGENT / AI_AGENT / Claude /
Cursor / Codex env markers).
It runs on every agent push — draft, ready, or no PR at all. There is no browser or API suite here that a draft could usefully defer, and a pushed commit that does not compile or whose tests fail is worth nothing to a reviewer whatever the PR says.
The gate is pnpm check → pnpm lint → pnpm test, which is what CI runs and
nothing more. This is a library monorepo — no application to stand up, no browser
suite — so the unit run is the gate. It caches the validated HEAD SHA in
.run/agent-ship-gate.json, so one commit is validated once however many
times you push or publish it. Force a re-run with AGENT_SHIP_GATE_FORCE=1.
Do not hand-run pnpm check / lint / test as routine verification.
Validating the same commit repeatedly costs the same each time and proves nothing
the first run did not. While iterating, narrow proof is the right tool — the one
test you are fixing, or a typecheck of the package you touched. Whole-gate runs
belong to the push.
.envrc puts the gh shim on PATH; run direnv allow once after cloning
(command -v gh should print .tools/bin/gh). Never --no-verify, and never set
SKIP_AGENT_PREPUSH — that is the human escape hatch.
- Zero Tolerance for Errors: All automated checks must pass
- Root causes, not workarounds: When something fails — a type error, a runtime crash, a failing test, a hanging fiber — diagnose the invariant that was violated and fix it there. Do not mask the symptom with a wrapper, a revive helper, a
try/catch, aJSON.parse(JSON.stringify(...)), or any other shape-coercion. Each of these is a tell that you skipped the diagnosis. - No
as any/as unknowncasts: A specific case of the rule above. Casts hide the type system telling you the shape is wrong. Understand the actual types and fix the root cause. If a type mismatch exists, find the correct v4 API, update the type signatures, or restructure the code. - No hand-rolled deserialization: Anything stored as JSON and read back must round-trip through a Schema codec (
S.fromJsonString(S.toCodecJson(...)),S.encodeEffect/S.decodeEffect). Class prototypes —Exit,Cause,Workflow.Result,Schema.Classinstances — do not surviveJSON.parse. The schema layer is the canonical reconstruction path; writing a customreviveXhelper is a smell that the codec is missing. - Search prior art before writing helpers: Before introducing a new utility for a cross-boundary concern (serialization, encoding, retries, context propagation, OCC), grep the repo and
repos/effectfor the established pattern. The cluster engine, the existing Cosmos adapter, the SQL stores — they have already solved most of these problems. Copying the established pattern beats inventing a parallel one. - Check newer branches before resuming work: A new session may resume on
mainwith no checkout of relevant feature branches. Before extending a topic, rungit branch -a | grep <topic>and inspect the latest version withgit show <branch>:<path>. Session-local memory of "how X works" may be from an older draft that has since been replaced. - Clarity over Cleverness: Choose clear, maintainable solutions
- Conciseness: Keep code and any wording concise and to the point. Sacrifice grammar for the sake of concision.
- Reduce comments: Avoid comments unless absolutely required to explain unusual or complex logic. Comments in jsdocs are acceptable.
- Look for effect sources inside
repos/effect - Never import local
reposfiles: Always use the latest online versions of packages instead. - Never webfetch from the effect repos: just use the locally included under
repos
Errors are signals, not nuisances. Run this checklist:
- State the failure precisely. Quote the exact error or behavior.
"object is not iterable"is not the same as"wrong type". The message names the violated invariant. - Name the boundary. Where in the data flow did the value lose the property the consumer needs? At the storage write, the storage read, the network hop, the schema decode, the context provision?
- Search for prior art at that boundary.
grepthe repo +repos/effectfor how similar values cross the same boundary. If a pattern exists, use it. - Only then write code. If your fix re-implements something the searched-for pattern already does (revive prototypes, retry on OCC, encode JSON), stop — use the existing pattern instead.
Anti-patterns that mean you skipped the checklist:
JSON.parseof a value that contains tagged classes, followed by manual prototype reconstruction.try/catcharound a yield that swallows the error and returns a fabricated value.- Adding a second
as anyto make a firstas anytypecheck. - Adding a sleep / retry to "give it time to work" instead of finding the missing wake signal.
- Disabling a hook (
--no-verify) or a check to make the diff land.
The ship gate owns validation — see The gate runs the checks — you do not. It
runs pnpm check → pnpm lint → pnpm test on every push and on publish, once
per commit.
pnpm lint-fix is the one thing worth running by hand, because it writes: it
formats and auto-fixes across packages, and the gate only reports what it would
have fixed. Run it when you are done editing, stage what it changes, then push.
If type checking fails in a way that makes no sense against the diff, pnpm clean
clears the caches — stale incremental state produces phantom errors.
Always look at existing code in the repository to learn and follow established patterns before writing new code.
Do not worry about getting code formatting perfect while writing. Use pnpm lint-fix
to automatically format code according to the project's style guidelines.
Instead of writing:
const fn = (param: string) =>
Effect.gen(function*() {
// ...
})Prefer:
const fn = Effect.fnUntraced(function*(param: string) {
// ...
})Prefer the class syntax when working with Context.Service. For example:
import { Context } from "effect-app"
class MyService extends Context.Service<MyService, {
readonly doSomething: (input: string) => number
}>()("MyService") {}Avoid .length > 0 or .length === 0 or !.length or !!.length checks, use Array.isArrayNonEmpty for type narrowing by default.
Effect.provide(self, layer) resolves its MemoMap from the ambient fiber
context. On an HTTP server, that MemoMap lives on the server fiber and is
shared by every request that server handles. The first request to build a
stateful layer (anything using Layer.effect / Effect.acquireRelease)
memoizes the resulting value onto the server fiber; every subsequent request
then receives the same instance — including its clear() / dispose
finalizer, which now fires at the wrong time.
When you call Effect.provide(layer) (or Stream.provide(layer)) inside a
per-request hot path:
- Pass
{ local: true }if the layer is pure / stateless or you genuinely want it scoped to this effect only, or - Build it explicitly against the request scope with a fresh
MemoMap(Layer.makeMemoMap+Layer.buildWithMemoMap(layer, memoMap, requestScope)) — seeprovideOnRequestScopeinpackages/infra/src/setupRequest.ts.
If neither option is taken and the layer carries state, the state leaks
across requests. Concrete repro lives in
packages/infra/test/rpc-context-map-streaming.test.ts (the overlapping
requests case).
All pull requests must include a changeset. You can create changesets in the
.changeset/ directory.
The have the following format:
---
"package-name": patch | minor | major
---
A description of the change.