3. Put the domain in Effect#
Goal: define typed business failures and services without making the Craft component know how they are provided.
Typed failures are values#
Effect's tagged errors map naturally to Craft's exception channel:
import { Context, Data, Effect } from 'effect';
export class UserNotFound extends Data.TaggedError('UserNotFound')<{
readonly userId: string;
}> {}
export class Unauthorized extends Data.TaggedError('Unauthorized')<{
readonly reason: string;
}> {}
type User = {
readonly id: string;
};
type UserRepository = {
readonly find: (userId: string) => Effect.Effect<User | undefined>;
};
export class UserRepositoryService extends Context.Service<
UserRepositoryService,
UserRepository
>()('app/UserRepository') {}
export const loadUser = Effect.fnUntraced(function* (userId: string) {
const repository = yield* UserRepositoryService;
const user = yield* repository.find(userId);
if (!user) return yield* new UserNotFound({ userId });
return user;
});
The program has the shape Effect<User, UserNotFound, UserRepositoryService>. yield* UserRepositoryService gets the repository from the Effect context; yield* repository.find(userId) then runs the Effect returned by its method. UserNotFound is a business outcome that the UI can handle. An unexpected defect raised by Effect.die remains a technical error; it is not turned into a business exception.
Data.TaggedError creates a yieldable error in Effect v4, so this is the idiomatic form inside Effect.gen:
if (!user) return yield * new UserNotFound({ userId });
The explicit equivalent is yield* Effect.fail(new UserNotFound({ userId })); there is no Effect.failed constructor. At the Craft boundary, yield the effect through runEffect(...) instead of yielding the error instance directly.
Define an Effect service#
Use Context.Service for the contract and a Layer for the implementation:
import { Context, Effect, Layer } from 'effect';
type AccessPolicy = {
readonly decide: (
userId: string,
) => Effect.Effect<AccessDecision, UserNotFound>;
};
export class AccessPolicyService extends Context.Service<
AccessPolicyService,
AccessPolicy
>()('app/AccessPolicyService') {}
export const AccessPolicyLive = Layer.sync(AccessPolicyService)(() => ({
decide: (userId) => findAccessDecision(userId),
}));
export const checkUserAccess = Effect.fnUntraced(function* (userId: string) {
const policy = yield* AccessPolicyService;
return yield* policy.decide(userId);
});
The component calls checkUserAccess; it does not call AccessPolicyService and does not know which Layer implements it.
When a Craft factory genuinely needs a service member, narrow it explicitly with effectService rather than resolving an untracked value:
import { effectService } from '@craft-ts/effect';
const { decide } =
yield * effectService(AccessPolicyService, ({ decide }) => ({ decide }));
Prefer exposing a domain operation such as checkUserAccess to a component. The selector form is useful for a Craft service or adapter that deliberately owns the boundary and wants the graph to record only the members it uses.
Derive Craft state from the Effect service#
craftComputed stays synchronous: it derives a Craft reader. Let queryEffect execute the Effect operation, then derive a display value from the query resource:
import { craftComputed, craftUse } from '@craft-ts/core';
import { queryEffect } from '@craft-ts/effect';
const accessQuery =
yield *
queryEffect('accessQuery', {
params: () => 'user-ada',
loader: ({ params }) => checkUserAccess(params),
});
const accessLabel = craftUse(craftComputed('accessLabel', function* () {
return (yield* accessQuery.value())?.label ?? 'Loading…';
}));
The chain is: queryEffect runs checkUserAccess, the active Layer provides AccessPolicyService, and accessLabel reacts to the query's Craft value. The computed does not call the Effect service or start an Effect itself.
Declare a synchronous member#
That last sentence used to be a hard rule: no Effect at all inside params, craftComputed(...) or craftMethod(...). Those run on Craft's synchronous driver, which completes on one tick and cannot wait — and Effect<A, E, R> does not say whether running it will suspend.
It is worse than it looks for a service member. A Layer closes over the member's dependencies when it builds the service, so a member that calls the network and a member that adds two numbers both surface as R = never:
import { Context, Effect, Layer } from 'effect';
import { SyncOp } from '@craft-ts/effect';
export type CartLine = {
readonly sku: string;
readonly qty: number;
readonly unitCents: number;
};
export type CartPricingShape = {
// Asynchronous: `R` is `never` and it still goes to the network — the Layer
// closed over the transport at construction. Nothing in the type says so.
readonly fetchCatalog: (
skus: readonly string[],
) => Effect.Effect<ReadonlyMap<string, number>>;
// Synchronous: `SyncOp` in `R` is the whole difference.
readonly lineTotal: (line: CartLine) => Effect.Effect<number, never, SyncOp>;
readonly formatPrice: (cents: number) => Effect.Effect<string, never, SyncOp>;
};
export class CartPricing extends Context.Service<
CartPricing,
CartPricingShape
>()('learn-effect/CartPricing') {}
export const CartPricingLive = Layer.sync(CartPricing)(() => ({
fetchCatalog: Effect.fnUntraced(function* (skus: readonly string[]) {
yield* Effect.sleep('50 millis');
return new Map(skus.map((sku) => [sku, 1_000]));
}),
// The shape already declares these synchronous, so the implementations need
// no ceremony: Effect<A, E, never> is assignable to Effect<A, E, SyncOp>.
lineTotal: (line) => Effect.succeed(line.qty * line.unitCents),
formatPrice: (cents) => Effect.succeed(`${(cents / 100).toFixed(2)} €`),
}));
The information does not exist in the type, so you write it there. SyncOp is a phantom requirement — never provided, no runtime cost — and R is the one channel Effect accumulates across composition. An Effect that requires SyncOp is one its author declares never suspends.
Requirements union through Effect.gen, so the declaration propagates on its own. A standalone program that only calls declared-synchronous members inherits the marker; one that calls nothing marked spells it out with yield* SyncOp:
/**
* `R` is inferred here: `CartPricing` from the tag, `SyncOp` from the members.
* Composition propagates the declaration — nothing to maintain by hand.
*/
export const cartTotalLabel = Effect.fnUntraced(function* (
lines: readonly CartLine[],
) {
const pricing = yield* CartPricing;
let cents = 0;
for (const line of lines) {
cents += yield* pricing.lineTotal(line);
}
return yield* pricing.formatPrice(cents);
});
/** No marked call to inherit from, so the marker is spelled out. */
export const cartWeight = Effect.fnUntraced(function* (
lines: readonly CartLine[],
) {
yield* SyncOp;
return lines.reduce((total, line) => total + line.qty * 250, 0);
});
CartPricing in R is not a problem: the level in force satisfies it, exactly as it does for a loader. The only thing checked is that SyncOp is among the requirements.
Use it: computedEffect, then syncEffect#
For a derived value, reach for computedEffect — the Effect counterpart of craftComputed. The factory reads Craft dependencies and returns the Effect; the adapter runs it in place, so you get a plain reactive value:
import { craftComponent, p } from '@craft-ts/component';
import { state } from '@craft-ts/core';
import { computedEffect } from '@craft-ts/effect';
export const CartTotal = craftComponent('LearnEffectCartTotal', {}, function* () {
const lines = yield* state('lines', [
{ sku: 'sku-1', qty: 2, unitCents: 1_000 },
{ sku: 'sku-2', qty: 1, unitCents: 1_000 },
] as CartLine[]);
// The factory RETURNS the Effect; `computedEffect` runs it in place.
const totalLabel = computedEffect('totalLabel', function* () {
return cartTotalLabel(yield* lines());
});
return [p(totalLabel)];
});
Anything nobody declared synchronous is refused at the call, before anything runs:
const catalogProgram = Effect.gen(function* () {
const pricing = yield* CartPricing;
return yield* pricing.fetchCatalog(['sku-1']);
});
export function cartSummary() {
// ✅ declared synchronous — `SyncOp` is in its requirements.
const weightLabel = computedEffect('weightLabel', () => cartWeight([]));
// ❌ `fetchCatalog` suspends and nobody declared otherwise: a computation
// cannot run it. Use queryEffect.
const catalogLabel = computedEffect(
'catalogLabel',
// @ts-expect-error NotDeclaredSynchronous
() => catalogProgram,
);
return { weightLabel, catalogLabel };
}
For a synchronous Effect exposed as a callable method, use methodEffect, the Effect counterpart of craftMethod. For lower-level positions such as a params factory or a state updater, syncEffect(...) is the same door, opened by hand:
queryEffect('shippingQuote', {
params: function* () {
return yield* syncEffect(cartWeightGrams(yield* lines()));
},
loader: ({ params }) => quoteShipping(params),
});
The relationship mirrors the asynchronous side: computedEffect is to methodEffect what queryEffect is to asyncProcessEffect — a value or method with no resource lifecycle. syncEffect remains the lower-level escape hatch.
Three lines of defence
SyncOp is a claim, not a proof — nothing stops a body from declaring itself synchronous and awaiting anyway. Three mechanisms check it, and none is redundant:
the type —
computedEffectandsyncEffect(...)refuse an Effect nobody declared, at the call site;craft-ts/sync-effect-body— reads the body, every branch at once, and rejects a declared-synchronous body that yields something async. A unit test cannot do this: it only proves the inputs it was given;the runtime — both run through
Effect.runSyncExitWith, which cannot suspend. A broken declaration throwsCraftEffectNotSynchronousimmediately, at the first call, instead of freezing the UI.
Keep asynchronous work where it belongs: a loader. SyncOp opens one narrow, explicit door for business calculations, not a way around the adapters.
Run a standalone Effect#
For a low-level bridge, runEffect lets a Craft generator yield an Effect while preserving its typed error channel:
import { Effect } from 'effect';
import { runEffect } from '@craft-ts/effect';
const name = yield * runEffect(Effect.succeed('Ada'));
Use the adapters in the next chapters for application data. They resolve the Effect requirement R through the nearest provideLayer(...) and keep loading, value and exception state in the Craft resource.
Install the bridge once#
The bridge teaches Craft how to execute a yielded Effect. Install it during app bootstrap, not in every loader:
import { provideAppInitializer } from '@craft-ts/core';
import { installCraftEffectBridge } from '@craft-ts/effect';
export const appConfig = craftAppConfig({
providers: [
provideAppInitializer(() => {
installCraftEffectBridge();
}),
],
});
In tests, call installCraftEffectBridge() in beforeEach and dispose the returned function in afterEach.
What you gained#
An Effect domain with typed failures, explicit service requirements and swappable Layers. The next step puts that program behind a reactive queryEffect.