i18n with Effect
@craft-ts/i18n-effect is an adapter, and only an adapter. It exposes three things — a service tag, a Layer, and one function — over a runtime you built the ordinary way. @craft-ts/i18n itself never imports Effect, and plain component code should keep calling t directly.
The Layer
import { Effect } from 'effect';
import { provideI18nRuntime, translateEffect } from '@craft-ts/i18n-effect';
const runtime = createI18nRuntime({ locales, defaultLocale: 'en-US' });
// One Layer, built from the runtime the rest of the app already uses.
export const i18nLayer = provideI18nRuntime(runtime);provideI18nRuntime(runtime) returns Layer.Layer<I18nEffectService>. It wraps the runtime you already have, so there is exactly one active locale in the process — the Effect side does not get its own.
Bind the locales once
translateEffect has no value parameter carrying the locales, so TypeScript has nothing to infer them from. Called bare, its key parameter resolves to never and even a valid key is rejected. Bind them once, in the same file as the Layer:
import type { TranslationKey, TranslationParams } from '@craft-ts/i18n';
type AppLocales = typeof locales;
/**
* `translateEffect` has no value parameter carrying the locales, so TypeScript
* cannot infer them: called bare, its key parameter resolves to `never` and
* even a valid key is rejected. Bind them once, here, and every call site gets
* the closed key union back.
*/
export const t = <Key extends TranslationKey<AppLocales[number]>>(
key: Key,
...params: keyof TranslationParams<
AppLocales[number],
Key & string
> extends never
? [params?: TranslationParams<AppLocales[number], Key & string>]
: [params: TranslationParams<AppLocales[number], Key & string>]
) => translateEffect<AppLocales, Key>(key, ...params);From there, t has the closed key union and the typed params back. Passing the type arguments at every call site — translateEffect<typeof locales, 'order.total'>(…) — works too, and is what this wrapper spares you.
translateEffect
// Same keys, same params, same string as runtime.t — but as an Effect that
// declares I18nEffectService in its requirements.
const summary = Effect.gen(function* () {
const total = yield* t('order.total', { amount: 1234.5 });
const items = yield* t('order.items', { count: 2 });
return `${total} ${items}`;
});The signature is translateEffect(key, params) => Effect.Effect<string, never, I18nEffectService>. Same closed key union, same typed params, same string as runtime.t — the snippet above is checked against runtime.t in the docs test suite rather than trusted.
The error channel is never on purpose: a translation that reaches this point cannot fail on a bad key or a bad parameter, because neither compiles. What can fail is the locale not being loaded, and that is a defect in the app's startup, which is why it throws I18nRuntimeError rather than becoming a typed failure every call site would have to handle.
When to reach for it
Use translateEffect inside an Effect program — a domain service building a message, a server handler rendering an email. In a component, the bound translator is the shorter path, and reaching for Effect just to format a string adds a requirement to the program for nothing.
An Effect program is not a Craft injection context, so translateEffect accepts StaticTranslationKey — the keys whose formatting resolves no service. A message whose token yields a Craft service is rendered by the component-side translator; that is what keeps the never error channel above honest.
See also the Effect adapters page for the rest of the @craft-ts/*-effect family.