Effect
Effect.ts v4 adaptor for Command Actions, Contexts, and Core errors.
@crustjs/effect lets you author Command Actions and Contexts in Effect idiom with nothing restated. Crust remains the runtime: routing, parsing, rendering, and cleanup are unchanged, and applications that do not use Effect are unaffected.
Effect v4 prerelease
The adaptor targets effect@4.x prereleases and is published under the next dist-tag. Install
the exact effect version the package was tested against; a second copy of effect breaks
service identity, so it is a peer dependency.
Install
npm install @crustjs/effect@next effect@4.0.0-rc.115@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", () => ({ 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.
Mental model
Crust is the outer runtime. Each handler() invocation opens one Effect run, provides it with every layer() on the command path, and closes the run before Crust's own cleanup releases the Layers.
| Boundary | Crust side | Effect side |
|---|---|---|
| Action | .action(handler(fn)) | fn returns an Effect or is a generator (function*) |
| Layer-backed Context | .provide(db()), .use(db), ctx.db | yield* Db — services are provided, nothing to list |
| Plain Context inside a program | defineContext("config", …) | yield* service(config) |
| Throwing Crust code in a program | prompts, CrustError, AbortError | tryCrust(() => …) → tagged failure / interruption |
Errors reaching execute() | rendered exactly like a plain thrown error | unwrapped from the Exit, see below |
Error outcomes
| Program outcome | What the user sees |
|---|---|
| Success | The action resolves with the value |
Fail with a tagged Crust error | The wrapped CrustError is rethrown; Core renders it as if the action threw it |
Fail with any other error | Rethrown as-is; rendered like a plain thrown error (exit 1) |
Die (defect) | Rethrown as-is; rendered like a plain thrown error (exit 1) |
| Interruption | An AbortError is rethrown; Core exits 130 with no output, like prompt cancellation |
layer(name, layer)
function layer<Name extends string, ROut, E>(
name: Name,
layer: Layer.Layer<ROut, E>,
): ContextFactory<Name, void, LayerValue<ROut>>;Takes one fully composed Layer (no remaining requirements — the type rejects RIn ≠ never) and returns an ordinary Crust Context factory. Attach it with .provide(db()), declare it in inert definitions with .use(db), and read it from hooks or plain actions as Context.get(await ctx.db, Db).
Setup opens a fresh Effect Scope, builds the Layer inside it, and registers a Scope.close on the invocation's disposal stack. Resources are released after post-run hooks in reverse order with every other Context. Finalizers receive the handler's Exit, so an Effect.acquireRelease release step can commit on success and roll back on failure or interruption. A Layer pulled without a handler() having run closes with Exit.void.
The Context value is the built Context.Context<ROut>, branded as LayerValue<ROut> so handler() knows which ctx entries provide services.
handler(fn)
import { Crust } from "@crustjs/core";
import { handler, layer } from "@crustjs/effect";
import { Context, Effect, Layer } from "effect";
class Db extends Context.Service<Db, { readonly query: (sql: string) => string }>()("app/Db") {}
const db = layer("db", Layer.succeed(Db, { query: (sql) => `rows for ${sql}` }));
const base = new Crust("app").provide(db()).args({ name: "table", type: "string", required: true });
// Generator form: `fn` is a generator function, like `Effect.fn`.
base.action(
handler(function* ({ args }) {
const d = yield* Db;
return d.query(`select * from ${args.table}`);
}),
);
// Effect form: `fn` returns an Effect.
base.action(handler(({ args }) => Effect.map(Db, (d) => d.query(`select * from ${args.table}`))));fn receives the same input a plain action receives: args, flags, ctx, rawArgs, command, rootCommand, stdout, and stderr. Inference is unchanged. Both forms produce identical results, and the adapted action resolves with the program's success value.
The program's requirements are checked against ServicesOf<Input>: the union of services provided by the layer() Contexts in ctx. A program requiring a service with no layer() on the command path is a compile error. A plain defineContext that happens to return a Context.Context does not count, and neither does a layer() whose name is not a string literal. handler() also provides one internal service carrying the action input; service() requires it, so service() only runs inside a handler() program.
Every layer() on the path is built eagerly
When a handler() action starts, every layer() Context on the command path is built, whether or
not the program uses it — the same trade Effect CLI makes with Command.provide. Plain Contexts
stay lazy. Attach Layers to the commands that need them.
service(factory)
import { Crust, defineContext } from "@crustjs/core";
import { handler, service } from "@crustjs/effect";
import { Effect } from "effect";
const config = defineContext("config", () => ({ limit: 10 }));
new Crust("app").provide(config()).action(
handler(function* () {
const cfg = yield* service(config); // typed { limit: number }
return cfg.limit;
}),
);
// Off the path: fails with CrustDefinitionError, catchable by tag.
new Crust("app").action(
handler(() =>
service(config).pipe(
Effect.map((cfg) => cfg.limit),
Effect.catchTag("CrustDefinitionError", () => Effect.succeed(0)),
),
),
);Pulls a plain Crust Context from inside a handler() program, typed by the factory's value (FactoryValueOf<F>) and failing with CrustTaggedError. Like ctx.<name>, it is lazy: untouched Contexts never build, and repeated pulls share one build. The effective (last same-name) provider must come from the same factory, including that factory's .of(...) test doubles; a shadowed ancestor does not satisfy this requirement. An absent factory or a different same-name factory (even with a compatible value shape) fails with Core's missing-context CrustError, surfaced as a CrustDefinitionError for Effect.catchTag. Factory identity on the path is checked at runtime, not compile time.
service() requires the internal input service that handler() provides, so it cannot be run outside a handler() program; that service is not exported.
Tagged errors
import { CrustError } from "@crustjs/core";
import { tryCrust } from "@crustjs/effect";
import { Effect } from "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)));One Data.TaggedError class exists per Core error code: CrustDefinitionError, CrustValidationError, CrustParseError, and CrustCommandNotFoundError. Each carries message, details (typed by the corresponding Core details type), and cause (the original CrustError).
fromCrustError(error)wraps a caughtCrustErrorin the class for itscode.tryCrust(thunk)lifts a sync or async thunk into an Effect: a thrownCrustErrorfails with its tagged class, anAbortError(prompt cancellation) interrupts the fiber, and anything else is a defect.
Exports
import {
layer,
handler,
service,
tryCrust,
fromCrustError,
CrustDefinitionError,
CrustValidationError,
CrustParseError,
CrustCommandNotFoundError,
} from "@crustjs/effect";The package also exports the CrustTaggedError, LayerValue, and ServicesOf types. import * as CrustEffect from "@crustjs/effect" keeps the short names out of your module scope when they clash with Effect's own Layer/Context.
Out of scope
The adaptor is a set of helpers, not an Extension: it registers no flags, commands, or hooks. Sharing a Layer between two layer() Contexts (compose in Effect-land and pass one Layer), embedding a Crust application inside an Effect program, ManagedRuntime, lazy per-service Layer building, and exporting HandlerInput are out of scope.