Inkpipe is a manga pipeline manager: search via Prowlarr, download torrents via AllDebrid, convert via KCC, upload to Copyparty, browse on Komga. It's a monorepo with a React frontend and a Bun backend — all built with Effect-TS.
I'd been hearing about Effect for a while. People describe it as "ZIO for TypeScript" (for Scalaist) or "a standard library for TypeScript that happens to solve dependency injection and error handling." I wanted to try it on something real — not a todo app.
Here's what I learned, with concrete patterns from the codebase.
1. Errors are values, not surprises
In most TypeScript backends, error handling looks like this:
try {
const result = await externalService.fetch();
} catch (err) {
// err is `unknown`. Good luck.
if (err instanceof FetchError) {
/* ... */
}
}
Effect flips this: errors are part of the return type. You don't catch; you pattern-match. Here's every domain error in Inkpipe:
// packages/shared/src/errors.ts
import { Schema } from "effect";
export class ProwlarrNotConfigured extends Schema.TaggedError<ProwlarrNotConfigured>()(
"ProwlarrNotConfigured",
{ message: Schema.String },
) {}
export class ProwlarrHttpError extends Schema.TaggedError<ProwlarrHttpError>()(
"ProwlarrHttpError",
{ message: Schema.String, status: Schema.optional(Schema.Number) },
) {}
export class MagnetUploadError extends Schema.TaggedError<MagnetUploadError>()(
"MagnetUploadError",
{ message: Schema.String },
) {}
// 17 more: AllDebridNotConfigured, AllDebridHttpError, KccError,
// PipelineError, WatchNotFoundError, NotFoundError, ValidationError...
Each error is a class with a string tag and typed fields. The magic: because they're Schema.TaggedError, you get instanceof checks, Schema validation, and a discriminated union at the type level — all from that one line.
A Prowlarr search function then returns:
search(query: string): Effect<ProwlarrResult[], ProwlarrNotConfigured | ProwlarrHttpError>
The error types are in the signature. You can't forget to handle them — the compiler won't let you call the function without dealing with both error cases. If you later add a ProwlarrRateLimitError, every call site becomes a compile error until you handle it. This is the killer feature.
2. Services are just tags, layers are just wiring
There's no @Injectable() decorator, no IoC container, no reflection. Services in Effect are two things: a Tag (the name) and a Layer (the construction).
// packages/server/src/layers/Config.ts
import { Effect, Layer } from "effect";
import { SqlClient } from "@effect/sql";
import { ConfigLoadError, ConfigSaveError } from "@inkpipe/shared";
export class ConfigService extends Effect.Tag("ConfigService")<
ConfigService,
{
readonly loadConfig: Effect.Effect<AppConfig, ConfigLoadError>;
readonly saveConfig: (
config: AppConfig,
) => Effect.Effect<void, ConfigSaveError>;
}
>() {}
export const ConfigServiceLive = Layer.effect(
ConfigService,
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
const loadConfig = Effect.gen(function* () {
const [prowlarrRows, alldebridRows, kccRows, komgaRows, copypartyRows] =
yield* Effect.all(
[
sql`SELECT url, api_key FROM prowlarr_config WHERE id = 1`,
sql`SELECT api_key FROM alldebrid_config WHERE id = 1`,
sql`SELECT * FROM kcc_config WHERE id = 1`,
sql`SELECT url, api_key, default_library FROM komga_config WHERE id = 1`,
sql`SELECT url, path, password FROM copyparty_config WHERE id = 1`,
],
{ concurrency: "inherit" },
);
return {
prowlarr: {
url: prowlarrRows[0]?.url ?? "",
apiKey: prowlarrRows[0]?.api_key ?? "",
},
// ...
};
}).pipe(
Effect.mapError((e) => new ConfigLoadError({ message: String(e) })),
);
return { loadConfig, saveConfig };
}),
);
What's happening here:
ConfigServiceis a Tag — it declares "this service exists" and what its interface looks like.ConfigServiceLiveis a Layer — it declares how to build the service. It depends onSqlClient(pulled viayield*).Effect.gen(function* () { ... })is the generator syntax.yield*unwraps effects — it's likeawaitbut with full type inference.Effect.mapErrortransforms any error into aConfigLoadError. The error channel is always typed.
Compare this to a typical NestJS service with @Injectable(), constructor DI, and catch blocks. The Effect version is fewer lines, fully typed end-to-end, and requires no decorators or module system.
3. HTTP routes are just Effects with an error boundary
The cleanest pattern in the whole project is how route handlers work:
// packages/server/src/routes/search.ts
import { Effect } from "effect";
import { ProwlarrService } from "../layers/Prowlarr";
export const searchHandler = (query: string) =>
Effect.gen(function* () {
const prowlarr = yield* ProwlarrService;
const results = yield* prowlarr.search(query);
return Response.json(results);
}).pipe(
Effect.catchAll((e: { message: string }) =>
Effect.succeed(Response.json({ error: e.message }, { status: 502 })),
),
);
The handler itself is an Effect generator. It pulls ProwlarrService from the environment with yield*. If everything works, it returns a Response. If anything fails — ProwlarrNotConfigured, ProwlarrHttpError — the catchAll at the bottom converts it to a JSON error response.
The handler never throws. Every error path is an Effect.succeed returning a Response. This pattern repeats in every route: the error boundary is explicit, at the edge, in one place.
Even better, you can selectively suppress errors when it makes sense. In the watch trigger handler:
const results =
yield *
prowlarr.search(watch.query).pipe(Effect.catchAll(() => Effect.succeed([])));
If Prowlarr is down, the watch trigger continues gracefully with an empty result set — no crash, no try/catch, just a one-liner that says "fall back to empty."
4. Layer composition reads like a dependency graph
The main.ts entry point builds the full service graph bottom-up:
// packages/server/src/main.ts
import { Layer, ManagedRuntime } from "effect";
import { DbMigratedLayer } from "@inkpipe/db";
// Bottom layer: database, logging, filesystem
const BaseLayer = Layer.mergeAll(
DbMigratedLayer,
Layer.provideMerge(PushServiceLive, LogServiceLive),
FileManagerServiceLive,
);
// Services that only need the database
const ConfigLayer = Layer.provide(ConfigServiceLive, BaseLayer);
const JobStoreLayer = Layer.provide(JobStoreServiceLive, BaseLayer);
// Services that need Config + Base
const ProwlarrLayer = Layer.provide(
ProwlarrServiceLive,
Layer.mergeAll(BaseLayer, ConfigLayer),
);
const AllDebridLayer = Layer.provide(
AllDebridServiceLive,
Layer.mergeAll(BaseLayer, ConfigLayer),
);
// Pipeline needs everything
const PipelineLayer = Layer.provide(
PipelineServiceLive,
Layer.mergeAll(
BaseLayer,
ConfigLayer,
JobStoreLayer,
AllDebridLayer,
KccLayer,
CopypartyLayer,
),
);
// Final composition — all services available
const MainLayer = Layer.mergeAll(
BaseLayer,
ConfigLayer,
JobStoreLayer,
ProwlarrLayer,
AllDebridLayer,
KomgaLayer,
CopypartyLayer,
KccLayer,
PipelineLayer,
WatchStoreLayer,
);
const runtime = ManagedRuntime.make(MainLayer);
// Bun HTTP server — every request runs through the runtime
Bun.serve({
fetch(req) {
return runtime.runPromise(router(req));
},
});
What I love about this:
- No hidden dependencies. Every service declares its dependencies at the
yield*level. The layer wiring inmain.tsmirrors exactly what each service needs. Layer.mergeAllflattens layers together.Layer.provide(ServiceLive, Deps)says "build ServiceLive, giving it Deps."- Dependency order is enforced. If
ProwlarrServiceLiveneedsConfigService, you must provide it. Forgetting a dependency is a compile error, not a runtime null. - One runtime for the whole app.
ManagedRuntime.make(MainLayer)creates a runtime that holds all services. Every HTTP request callsruntime.runPromise(handler)— the handler'syield*pulls services from that runtime's environment.
This is the inversion of control that DI containers promise but rarely deliver this cleanly.
5. Resource cleanup that can't be skipped
The pipeline — download torrent, poll AllDebrid, download files, convert via KCC, upload to Copyparty — is the most failure-prone code in the project. Five external services, Docker commands, filesystem operations. Things will go wrong.
Here's how cleanup works:
// packages/server/src/layers/Pipeline.ts
const runPipeline = (result: ProwlarrResult) =>
Effect.gen(function* () {
const job = yield* jobStore.createJob(result.title);
const log = yield* LogService.withJob(job.id);
const jobDir = yield* fileManager.ensureJobDir(job.id);
const pipelineBody = Effect.gen(function* () {
// Stage 1: Upload magnet to AllDebrid
yield* jobStore.updateJob(job.id, { stage: "UPLOADING", progress: 10 });
const magnet = yield* alldebrid.uploadMagnet(result.magnetUrl);
// Stage 2: Poll until ready
yield* jobStore.updateJob(job.id, {
stage: "DEBRID_PROCESSING",
progress: 30,
});
const files = yield* alldebrid.pollUntilReady(magnet.id);
// Stage 3: Download
yield* jobStore.updateJob(job.id, { stage: "DOWNLOADING", progress: 50 });
yield* alldebrid.downloadFiles(files, jobDir);
// Stage 4: Convert with KCC
yield* jobStore.updateJob(job.id, { stage: "CONVERTING", progress: 70 });
const outputFile = yield* kcc.convert(jobDir, outputDir);
// Stage 5: Upload to Copyparty
yield* jobStore.updateJob(job.id, {
stage: "COPARTY_UPLOAD",
progress: 90,
});
yield* copyparty.upload(outputFile);
yield* jobStore.updateJob(job.id, { stage: "DONE", progress: 100 });
});
const cleanup = Effect.gen(function* () {
yield* fileManager.cleanupJobDir(job.id).pipe(
Effect.catchAll(() => Effect.void), // cleanup failures are ok
);
});
yield* pipelineBody.pipe(
Effect.catchAllCause((cause) => {
const error = Cause.squash(cause);
yield *
jobStore.updateJob(job.id, { stage: "FAILED", error: String(error) });
}),
Effect.ensuring(cleanup),
);
});
The key parts:
pipelineBodyis the happy path — the five stages, each updating the job progress.cleanupremoves the temp directory, swallowing any errors (the temp dir might already be gone).Effect.catchAllCausecatches everything — both typed domain errors and unexpected defects — and marks the job as FAILED.Effect.ensuring(cleanup)is the functional equivalent offinally {}. It runs cleanup regardless of success, failure, or defect. You can't forget it, you can't skip it, and it's part of the effect composition rather than a separate block.
A traditional try/catch/finally would scatter this logic across three blocks. Here, it's a pipeline of transformations on a single value.
6. Parallel queries without Promise.all chaos
The config loading function needs to fetch 5 tables. With raw SQL, you'd do:
const [prowlarr, alldebrid, kcc, komga, copyparty] = await Promise.all([
db.query("SELECT ... FROM prowlarr_config"),
db.query("SELECT ... FROM alldebrid_config"),
db.query("SELECT ... FROM kcc_config"),
db.query("SELECT ... FROM komga_config"),
db.query("SELECT ... FROM copyparty_config"),
]);
Effect gives you the same thing with Effect.all:
const [prowlarrRows, alldebridRows, kccRows, komgaRows, copypartyRows] =
yield *
Effect.all(
[
sql`SELECT url, api_key FROM prowlarr_config WHERE id = 1`,
sql`SELECT api_key FROM alldebrid_config WHERE id = 1`,
sql`SELECT * FROM kcc_config WHERE id = 1`,
sql`SELECT url, api_key, default_library FROM komga_config WHERE id = 1`,
sql`SELECT url, path, password FROM copyparty_config WHERE id = 1`,
],
{ concurrency: "inherit" },
);
The difference: Effect.all preserves the type of each element in the tuple. If the first query returns ProwlarrRow[] and the second returns AlldebridRow[], destructuring gives you the exact types — no as casts, no generic Promise<unknown[]>.
It also handles errors at the Effect level: if any query fails, the whole Effect.all fails with a typed error, and you can mapError or catchAll on the combined result. With Promise.all, a single rejection loses the type information of all other promises.
What I'd do differently
Effect-TS is not without rough edges:
The learning curve is real. Concepts like Cause, Fiber, Scope, and the difference between catchAll and catchAllCause take time to internalize. The docs are good but dense. I spent my first week just reading the source code of @effect/sql to understand how queries compose.
Type errors can be cryptic. When you get a layer composition wrong, TypeScript's error message can be a wall of text. The compiler knows exactly what's missing — it just struggles to explain it in 200 characters. A helper like Layer.expect or better error messages would help.
The ecosystem is growing but small. @effect/sql covers SQLite and PostgreSQL well, but for third-party APIs (Prowlarr, AllDebrid, Komga), I had to write my own service layers. This is fine — the pattern is straightforward once you've done it once — but don't expect a nestjs/axios-level ecosystem of pre-built integrations.
Bridging to impure code is awkward. Effect's runtime manages all async execution. When you need to call child_process.spawn or stream a download with progress callbacks, you end up with Effect.runSync / Effect.runFork inside callbacks. It works, but it's a leak in the abstraction.
Despite these, I'd use Effect again. The type safety is real — I've caught more bugs at compile time with Effect than with any other TypeScript backend framework. The dependency injection is the simplest I've used. And the error handling model — errors as values in the type signature — is something I now miss in every other language.