Crust logoCrust

Effect

Effect.ts v4 adapter for Crust Command Actions, Contexts, and errors

@crustjs/effect lets you author Command Actions and Contexts in Effect idiom. Crust remains the runtime: routing, parsing, rendering, and cleanup are unchanged. It is a set of helpers, not an Extension, so it registers no flags, commands, or hooks.

Single copy of effect

effect is a peer dependency because a second copy breaks service identity. Make sure your app resolves one effect@4 install.

Install

npm install @crustjs/effect effect@4

@crustjs/core and effect are required peers. TypeScript is an optional peer.

Quick example

import { Crust, defineContext } from "@crustjs/core";
import { handler, layer, service } from "@crustjs/effect";
import { Context, Effect, Layer } from "effect";

class Db extends Context.Service<Db, { readonly query: (sql: string) => Promise<string> }>()(
	"app/Db",
) {}

const DbLive = Layer.effect(
	Db,
	Effect.acquireRelease(
		Effect.sync(() => ({ query: async (sql) => `rows for ${sql}` })),
		() => Effect.sync(() => console.log("db closed")),
	),
);

const db = layer("db", DbLive);
const config = defineContext("config").setup(() => ({ limit: 10 }));

const app = new Crust("app")
	.provide(db(), config())
	.args({ name: "table", type: "string", required: true })
	.action(
		handler(function* ({ args }) {
			const d = yield* Db;
			const cfg = yield* service(config);
			return yield* Effect.promise(() => d.query(`select * from ${args.table} limit ${cfg.limit}`));
		}),
	);

await app.execute();

DbLive is built when the action starts; db closed prints after the action finishes, whether it succeeded, failed, or was interrupted. config is a plain Crust Context and stays lazy.

How it fits

Each handler() invocation runs one Effect program, provided with every layer() on the command path. The Layers are released by Crust's normal Context cleanup afterwards.

BoundaryCrust sideEffect side
Action.action(handler(fn))fn returns an Effect or is a generator (function*)
Layer-backed Context.provide(db()), .use(db), ctx.dbyield* Db: services are provided automatically
Plain Context inside a programdefineContext("config").setup(…)yield* service(config)
Throwing Crust code in a programprompts, CrustError, AbortErrortryCrust(() => …): tagged failure or interruption

Error outcomes

Program outcomeWhat the user sees
SuccessThe action resolves with the value
Fail with a tagged Crust errorThe wrapped CrustError is rethrown; Core renders it as if the action threw it
Fail with any other errorRethrown as-is; rendered like a plain thrown error (exit 1)
Die (defect)Rethrown as-is; rendered like a plain thrown error (exit 1)
InterruptionAn AbortError is rethrown; Core exits 130 with no output, like prompt cancellation

The program and Layer builds run with the invocation's signal, so caller cancellation or Ctrl-C interrupts them. Interruption always becomes an AbortError, even when the caller aborted with a custom reason such as a TimeoutError. Layer finalizers still run; uninterruptible acquisition must settle before cleanup finishes.

layer(name, layer)

Turns one fully composed Layer into an ordinary Crust Context factory whose value is the built Context.Context. The Layer must have no remaining requirements: compose it in Effect first, then pass the result. Attach it with .provide(db()), declare it in reusable definitions with .use(db), and read it outside a handler as Context.get(await ctx.db, Db).

Each invocation builds the Layer in a fresh Scope, closed with the other Contexts after postRun hooks. Finalizers receive the handler's Exit, so an Effect.acquireRelease release can commit on success and roll back on failure or interruption. A Layer pulled without a handler() closes with Exit.void.

Unshadowed layer() providers are built eagerly

When a handler() action starts, every layer() Context on the command path is built, whether or not the program uses it. A layer() replaced by a same-name provider on a descendant command is not built. Plain Contexts stay lazy. Attach Layers to the commands that need them.

handler(fn)

class Store extends Context.Service<Store, { readonly query: (sql: string) => string }>()(
	"app/Store",
) {}
const store = layer("store", Layer.succeed(Store, { query: (sql) => `rows for ${sql}` }));

const base = new Crust("app")
	.provide(store())
	.args({ name: "table", type: "string", required: true });

// Generator form, like `Effect.fn`.
base.action(
	handler(function* ({ args }) {
		const s = yield* Store;
		return s.query(`select * from ${args.table}`);
	}),
);

// Effect form.
base.action(
	handler(({ args }) => Effect.map(Store, (s) => s.query(`select * from ${args.table}`))),
);

fn receives the same input a plain action receives, with the same inference, and the action resolves with the program's success value. The program's requirements are checked against ServicesOf<Input>, the services of the layer() Contexts on the path: requiring a service with no layer() there is a compile error. A plain Context whose setup returns a Context.Context, or a layer() without a literal name, does not count.

service(factory)

const limits = defineContext("limits").setup(() => ({ limit: 10 }));

new Crust("app").provide(limits()).action(
	handler(function* () {
		const { limit } = yield* service(limits); // { limit: number }
		return limit;
	}),
);

// Not provided on this path: fails with CrustDefinitionError, catchable by tag.
new Crust("app").action(
	handler(() =>
		service(limits).pipe(
			Effect.map(({ limit }) => limit),
			Effect.catchTag("CrustDefinitionError", () => Effect.succeed(0)),
		),
	),
);

Pulls a plain Crust Context inside a handler() program, typed by the factory's value. It is lazy like ctx.<name>, and repeated pulls share one build. It works only inside handler().

The effective provider on the path must come from the same factory, including its .of(...) test doubles. A missing factory, or a different factory with the same name, fails with a CrustDefinitionError. This is checked at runtime, not compile time.

Environment variables

Install @crustjs/env alongside the Effect adapter. defineEnv() returns a plain Crust Context, so the existing service() helper provides typed values and catchable errors without an additional Layer or adapter:

import { Crust } from "@crustjs/core";
import { handler, service } from "@crustjs/effect";
import { defineEnv } from "@crustjs/env";
import { Effect } from "effect";

const env = defineEnv("env", {
	PORT: { type: "number", default: 3000 },
	API_TOKEN: { type: "string", required: true },
});

const app = new Crust("app").provide(env()).action(
	handler(function* () {
		const {  } = yield* service(env);
		return PORT;
	}),
);

PORT is a number and API_TOKEN is a string. Validation runs only when the program reads service(env); repeated reads share the invocation's result. Providing the Context adds its Environment section to help and man pages without reading source values. Unhandled failures are restored to the original Core error for CLI reporting.

For commands that intentionally recover, such as a configuration diagnostic, catch CrustEnvError:

export const diagnostic = app.action(
	handler(() =>
		service(env).pipe(
			Effect.map(() => "Environment is valid"),
			Effect.catchTag("CrustEnvError", (error) =>
				Effect.succeed(
					error.details.issues.map((issue) => `${issue.name}: ${issue.received}`).join("\n"),
				),
			),
		),
	),
);

error.details.issues contains all invalid or missing variables. Each received field is only "missing" or "invalid", never the raw value. service(env) has the full CrustTaggedError error union: catching CrustEnvError leaves other failures, such as a missing provider, in the error channel. Definition mistakes still throw when defineEnv() is called, before the handler runs.

Use tryCrust(() => ctx.env) for explicit action-input access. Avoid Effect.promise(() => ctx.env) when you want typed recovery: rejected promises become defects rather than catchable tagged failures.

Tests can bypass environment reads and validation with the same factory's .of():

new Crust("test").provide(env.of({ PORT: 9000, API_TOKEN: "test" })).action(
	handler(function* () {
		return (yield* service(env)).PORT;
	}),
);

Tagged errors

import { CrustError } from "@crustjs/core";
import { tryCrust } from "@crustjs/effect";

const program = tryCrust(() => {
	throw new CrustError("PARSE", 'Unknown flag "--bogus"', {
		flag: "bogus",
		reason: "unknown-flag",
	});
}).pipe(Effect.catchTag("CrustParseError", (error) => Effect.succeed(error.details?.flag)));

Each Core error code has a Data.TaggedError class: CrustDefinitionError, CrustValidationError, CrustParseError, CrustCommandNotFoundError, and CrustEnvError. Each carries message, typed details, and cause (the original CrustError). CrustTaggedError is their union.

  • fromCrustError(error) wraps a caught CrustError in the class for its code.
  • tryCrust(thunk) lifts a sync or async thunk into an Effect: a thrown CrustError fails with its tagged class, an AbortError interrupts the fiber, and anything else is a defect.

import * as CrustEffect from "@crustjs/effect" keeps these names out of your module scope if they clash with Effect's own.

On this page