Small, standalone snippets for a video about the Effect TypeScript library. Each file demonstrates one idea and can be run on its own.
Uses Effect 4 (release candidate), pinned to effect@4.0.0-rc.117 (see Heads-up).
Requires Node.js 22.18+, which runs .ts files natively (no build step, no tsx).
npm install
npm run prepare # patches TypeScript with the Effect language service (see below)
node src/02-handling-errors.ts # run any demo
npm run typecheck # type-check all demos (incl. Effect-specific lint rules)npm install normally runs prepare automatically. It doesn't if npm's ignore-scripts setting is enabled (as it was on the machine this was set up on). In that case, run npm run prepare once after installing.
Recording tips
- Hover over variables in your editor. The type comments (
┌─── Effect<...>) in the files match what the compiler infers. Every one of them was checked. - No explicit return types: everything is inferred, so what you see on hover is exactly what TypeScript knows. With
Promise<User>, the possible errors are simply missing. src/lib/fake-clients.tsholds small fake clients (database, exchange-rate API, storage, moderation API). They return real Promises with simulated latency, so the demos call them like you'd call Prisma,fetchor the S3 SDK.- Lines under a
// ❌ Uncomment ...comment are intentional mistakes, kept commented out sonpm run typecheckpasses. Uncomment one on camera to show the red squiggle.
| File | Reason | Shows | Replaces |
|---|---|---|---|
00-effect-is-a-value.ts |
Foundation | Effects are lazy values: nothing runs until you run it, so you can enhance it first | – |
01a-typed-errors-without-effect.ts |
#1 Type safety | getUser is inferred as Promise<User>: the failures are invisible |
– |
01b-typed-errors-with-effect.ts |
#1 Type safety | The same getUser as Effect<User, InvalidId | DatabaseError | UserNotFound> (the rejected DB Promise mapped to a typed error) |
try/catch + unknown |
02-handling-errors.ts |
#1 Type safety | catchTag removes errors from the type; typos and double handling don't compile |
– |
03-errors-vs-defects.ts |
#1 Fine print | Only expected errors are typed; defects (bugs) are not | – |
04-services.ts |
#2 Dependencies | Requirements are part of the type; you can't run a program with missing deps | manual DI / singletons |
05-layers.ts |
#2 Dependencies | Layers build services; the dependency graph is checked; swap live/test | DI containers (InversifyJS…) |
06-retry-and-timeout.ts |
#3 Built-in | Exponential backoff + jitter + typed retry condition; timeout as a typed error or a fallback | p-retry, p-timeout |
07-concurrency.ts |
#3 Built-in | forEach with concurrency: 3 |
p-limit, p-map |
08-queue.ts |
#3 Built-in | Bounded queue with back-pressure, producer and consumer | p-queue, fastq |
09-rate-limiter.ts |
#3 Built-in | 2 calls/sec (delay mode) and a typed error (fail mode) | bottleneck, limiter |
10-schema.ts |
#3 Built-in | One schema gives you a type, validation, and transformation, with all errors reported | zod, valibot |
11-config.ts |
#3 Built-in | Typed env config, defaults, redacted secrets, typed ConfigError |
dotenv + zod + manual parsing |
12a-structured-concurrency-without-effect.ts |
Bonus | Promise.all: the upload keeps running after validation failed |
– |
12b-structured-concurrency-with-effect.ts |
Bonus | Effect.all: the upload is interrupted, and the storage request itself is aborted via AbortSignal |
– |
13-resource-safety.ts |
Bonus | acquireRelease: cleanup always runs; Scope appears in the type |
try/finally everywhere |
14-effect-language-service.ts |
Bonus | @effect/tsgo flags valid-TS-but-wrong-Effect code: never-run effects, untyped Error, v3 APIs |
– |
Run 11-config.ts with different env vars:
node src/11-config.ts # missing API_KEY -> typed ConfigError
API_KEY=secret-123 PORT=8080 node src/11-config.ts # valid
API_KEY=secret-123 PORT=abc node src/11-config.ts # invalid portStyle notes. The demos use Data.TaggedError for errors because it's the least noisy option. Effect's own agent guide (LLMS.md) prefers Schema.TaggedError, which gives serializable, schema-validated errors. Reusable functions use Effect.fn("name")(function* () {...}), the v4 idiom. It works like async function and also creates a tracing span.
Effect's own v4 docs open with this framing: "Typed data, untyped programs". Effect<Success, Error, Requirements> describes what a program produces, how it can fail, and what it needs.
This goes beyond "errors in the signature". The compiler tracks your error handling as you go:
catchTagremoves an error from the type.timeoutaddsTimeoutError.- Once everything is handled, the error type becomes
never(demos 02, 06).
Resources work the same way: Scope shows up in the type (demo 13).
Caveats worth saying on camera, so the video stays honest:
- Only expected errors are typed. Throwing inside
Effect.syncor a rejectedEffect.promisebecomes a defect, which is invisible in the type (demo 03). This is intentional: bugs aren't domain errors. But "every error is typed" would be an overclaim. - Your error types are only as good as your boundaries.
Effect.tryPromise(() => fetch(url))without acatchmapper gives you a genericUnknownError. You get precise types only if you map errors at the edges. - Tag strings must be unique. If two error classes use the same
_tag, neither the compiler nor the runtime warns you.catchTagcatches both. - In generators, write
return yield* new MyError(). Without thereturn, TypeScript won't narrow after the failure.
What's true:
- Required services show up in the type.
- You can't run a program until everything is provided (demo 04).
- Layers make the dependency graph explicit, and the compiler checks it's complete (demo 05).
- Swapping implementations for tests is trivial.
The Effect team leans hard into the AI angle:
- The docs say "Built for the AI era".
- The npm package ships
AGENTS.md/CLAUDE.md. - The repo has an agent guide (
LLMS.mdplus runnableai-docs/examples). - The Effect language service (
@effect/tsgo, set up in this project, see below and demo14) adds Effect-specific diagnostics totsc, such as never-run effects and outdated v3 APIs. Agents see these like any other compiler error.
Where I'd push back:
-
"Clear rules about how programs should be structured" is a bit strong. Effect is opinionated: services, layers, tagged errors,
Effect.fn. But it's a toolkit, not a framework that enforces an architecture, and there are often several ways to do the same thing:- generators vs.
pipe Data.TaggedErrorvs.Schema.TaggedErrorLayer.succeedvs.Layer.effect
What is enforced is that errors and dependencies must be declared. That's the part that actually helps agents: they can't silently forget a dependency or swallow an error without the types showing it. Pitch it that way.
- generators vs.
-
The big AI pitfall right now: v3 → v4 renamed a lot. Most LLM training data is v3 code, so out of the box, agents write v3 code that doesn't compile on v4. The compiler catches it (type safety pays off again), but expect friction. The Effect team's own advice is to vendor the Effect source into your repo (git subtree) and point the agent at
LLMS.md(blog post).v3 v4 Effect.catchAllEffect.catchContext.Tag/Effect.ServiceContext.ServiceEitherResultEffect.forkEffect.forkChildEffect.asyncEffect.callbackUnknownExceptionUnknownError@effect/platform,@effect/sql, …mostly inside effectitself (see below) -
A human still has to review the AI's output. With Effect, that requires knowing Effect. Agents lowering the barrier to writing Effect doesn't lower the barrier to reviewing it.
v4 consolidated the ecosystem into the single effect package. That includes:
- HTTP client/server
HttpApi(schema-first APIs with OpenAPI)- SQL, RPC, CLI
- AI (LLMs, tools, MCP)
- workflows, cluster
- persistence (
RateLimiter,KeyValueStore) - OTLP observability
Runtime and driver packages remain separate: @effect/platform-node, @effect/sql-pg, @effect/ai-anthropic, and so on.
The stronger argument isn't "fewer dependencies", it's "they all compose." Retry, timeout, queue, rate limiter, schema and config share one error channel, one interruption model, one DI system, and one logging/tracing system. p-retry + p-limit + bottleneck + zod + dotenv each have their own error model, and none of them can cancel the others.
Caveats:
- Some of it is officially
unstable. In the RC,RateLimiter, HTTP, SQL, AI and CLI live undereffect/unstable/*and may break in minor releases. The core modules used in demos 06–08 and 10–11 are stable:Effect,Schedule,Queue,Semaphore,Cache,Schema,Config,Stream. - Effect's
RateLimiteris a keyed, store-backed limiter. It uses an in-memory or Redis store, so it can rate-limit across processes. For simple in-process throttling, theconcurrencyoption (demo 07) or aSemaphoreis often all you need. - "Built in" doesn't mean every module beats the best standalone library in its niche (e.g. zod's ecosystem). Schema does support Standard Schema, which helps with interop.
- Structured concurrency and interruption (demos 12a/12b). Probably the most underrated feature. Promises can't be cancelled. In Effect, a failure in
Effect.all, atimeout, or a lostraceactually stops the other work. Wrapped promises receive anAbortSignal(Effect.tryPromise((signal) => fetch(url, { signal }))), so real requests get aborted too. - Programs as values (demo 00). This is the foundation that makes retries, timeouts and concurrency "just functions you apply". I'd explain it first. It's the mental shift everything else depends on.
- Resource safety (demo 13).
acquireReleaseguarantees cleanup on success, failure, and interruption.Scopein the type means you can't forget to define a resource's lifetime. - Testability. Beyond swapping layers (demo 05),
TestClockfromeffect/testinglets you test time-based code, like a retry policy with minutes of backoff, instantly. For Vitest integration there's@effect/vitest(it.effect). - Observability is built in.
Effect.fn("name")creates tracing spans automatically. Structured logging (the[time] INFO (#fiber)lines you see in the demos), metrics and an OTLP exporter ship in the box. - Incremental adoption. You don't have to rewrite your app. Run effects at the edges (
Effect.runPromise, orManagedRuntimeinside an Express/Hono handler) and grow from there. This is a big deal for viewers with existing codebases.
- Learning curve. Effect brings a lot of new vocabulary: fibers, layers, scopes, schedules,
yield*. It feels like a new way of writing TypeScript, and your team needs to buy in. - It spreads. Once functions return Effects, callers want to be Effects too. You need clear boundaries where Effect code meets non-Effect code.
- Overkill for small things. The payoff grows with complexity: many failure modes, concurrency, external integrations. For a 50-line script or a simple CRUD app, it's hard to justify.
- Bundle size. It tree-shakes, but there's a baseline runtime cost. Effect's own myths page cites ~25 KB gzipped as the minimum. That matters for frontends, rarely for backends.
- Performance is a common objection, but a weak one for app-level code (same myths page). The "500× slower" claims come from
1 + 1micro-benchmarks.
- Tutorials, StackOverflow answers and LLM knowledge are mostly v3. The docs for v4 are at effect.website/docs/v4.
- Import paths are about to change. A pending change on Effect's
mainbranch moveseffect/unstable/*toeffect/*:effect/unstable/persistencebecomeseffect/persistence,effect/unstable/httpbecomeseffect/http. The modules stay marked unstable. That's why this project pins4.0.0-rc.117. If you upgrade, only the import in09-rate-limiter.tsshould need changing.
@effect/tsgo is TypeScript that knows the rules of Effect. TypeScript only checks types. It doesn't know Effect's rules, so plenty of code is valid TypeScript but broken Effect code: an effect that's created but never run, a v3 API, running an effect inside another effect. @effect/tsgo is Effect's build of TypeScript 7 ("TypeScript-Go", Microsoft's native compiler) with 100+ Effect-specific rules compiled in. Its diagnostics show up in your editor and in plain tsc output, so an agent running npm run typecheck gets told "this is the v3 API catchAll, it was renamed to catch" instead of guessing.
It's set up here via npx @effect/tsgo setup. That added the tsconfig.json plugin entry, the prepare script (effect-tsgo patch, which patches the TypeScript binary in node_modules) and .vscode/settings.json. For editor squiggles, the setup tool says to install the TypeScript 7 extension and make sure the native TS server is active. Each release supports specific TS versions (0.45.0 supports 7.0.2), which is why typescript is pinned.
The same mistakes, run through plain TS 7 and through @effect/tsgo (demo 14):
| Mistake | Plain TypeScript | @effect/tsgo |
|---|---|---|
Effect.log(...) without yield* (never runs!) |
nothing | ❌ "This Effect value is neither yielded nor used in an assignment." |
Effect.fail(new Error("Payment declined")) |
nothing | |
Effect.catchAll(...) (v3 API) |
"Property 'catchAll' does not exist ..." | catchAll ... Renamed to catch." |
| Running a program with a missing service (demo 04) | "Effect<void, never, Mailer | UserRepo> is not assignable to Effect<void, never, never>" |
❌ "This Effect requires a service that is missing ...: Mailer | UserRepo" |
Every rule has a severity: error, warning, suggestion or off. Errors and warnings fail tsc. Suggestions are only printed. Several important anti-patterns are just suggestions by default, so neither CI nor an agent is forced to fix them. This project raises them to error in tsconfig.json:
| Rule | Catches |
|---|---|
runEffectInsideEffect |
Effect.runPromise/runSync inside an Effect (see below) |
tryCatchInEffectGen |
try/catch inside Effect.gen: bypasses typed error handling |
returnEffectInGen |
return someEffect instead of return yield* someEffect: a nested Effect<Effect<…>> that never runs |
leakingRequirements |
Service methods that expose their dependencies (the rule behind demo 05) |
catchUnfailableEffect |
Error handling on an Effect that can't fail (dead code / wrong assumption) |
Why running effects inside effects is bad (all verified at runtime). Effect.runPromise inside an Effect starts a separate, detached program:
- Cancellation is lost. An outer
timeoutfired, but the nested run kept going and finished anyway. - Typed errors become untyped. A typed
PaymentDeclinedfailure arrived as an untyped defect (Die). - Context is lost. Log annotations (e.g. a
requestId), tracing spans and provided services don't carry over. You'd have to provide dependencies again, e.g. rebuild a DB pool per call.
The fix is almost always a plain yield* (or Effect.forkChild for background work). Effect.runPromise belongs at the edges of your app.
More options:
- The rules are grouped into categories: correctness, anti-pattern, effect-native, style. Turn on more of them via
diagnosticSeverityintsconfig.json. npx @effect/tsgo setup --preset effect-nativeenables 22 "use the Effect way" rules:Effect.logoverconsole.log,Configoverprocess.env, noasyncfunctions, … That's great for real Effect apps. It's not used here because the demos compare against plain TS on purpose.strictEffectProvide("provide layers only at the entry point") is useful in apps but flags every entry point in these standalone demos.- An alternative is the official Oxlint integration (
oxlint+oxlint-tsgolint). It ships ready-made presets (recommended,correctness,antipattern,effect-native,style) and runs as a separate lint step (setup guide).
- Hook: "TypeScript types your data, not your programs."
- Foundation:
00 - Reason #1:
01avs.01b(split screen) →02, then03as the honesty moment - Reason #2:
04→05, plus the AI angle and the v3/v4 caveat - Reason #3:
06–11rapid-fire, framed as "this replaces library X" - What people miss:
12avs.12b,13,14(14fits well with the AI angle of reason #2) - Honest downsides + when not to use Effect