Skip to main content
@craft-ts
LearnLearn with EffectAI agentsGuideReference
@craft-ts/core@craft-ts/component@craft-ts/dev-tools@craft-ts/effect@craft-ts/style@craft-ts/style-testing@craft-ts/i18n@craft-ts/i18n-effect@craft-ts/mcp
ExamplesMigrationEffect compatibility & maturityAdopting CraftTS progressivelyPress kitRoadmapBacklog
Beta
  • Learn
  • Learn with Effect
  • AI agents
  • Guide
  • Reference
  • Packages
  • Resources
  • Guide overview
  • Create a project
i.Core concepts
  • The mental model
  • Which primitive should I use?
  • Anatomy of a primitive
  • Generators and yield*
  • Insertions
  • Typed insertion pipes
  • Exceptions as values
ii.Managing state
  • Local state
  • State machines
  • query
  • Mutations
  • queryParams
  • asyncProcess
  • Selecting
  • Reacting to mutations
  • Collections
  • Collection utilities
  • Persistence
  • GlobalPersisterHandler
  • Pagination placeholders
  • Schema validation
iii.Recommended approaches
  • Inject at the point of use
iv.Structuring the app
  • craftService
  • Service scopes
  • Shaping the public API
  • Abstract services
  • App start
  • Lazy services
  • Server functions
  • craftRegisterFor
  • Target wrapper
v.Routing & type-safe DI
  • Setup
  • CLI automation
  • ESLint rules
  • Route providers
  • Route guards
  • Route exception handling
  • Non-blocking navigation
  • Global error component
  • Route load errors
  • Scaling routes
vi.Components & templates
  • Components
  • Activating the style system
  • Typed styles
  • Defining a design system
  • Tokens and variables
  • Global foundation and fonts
  • Pseudo-elements and animations
  • Axes and the matrix
  • Context obligations
  • Testing visual states
  • Text contrast
  • Visual attestation
  • Template obligations
  • Typed CSS variables
  • Fine-grained reactivity
  • Progressive rendering
  • Directives and .pipe(...)
  • settledValue & pendingNode
  • Accessibility
  • Customization
  • Content projection
  • Styling a component
  • Template migrator
vii.Forms
  • Overview
  • Validators
  • Submitting
  • Nested forms
  • Exception handling
  • Complete examples
viii.Testing
  • Testing services
  • Testing components
  • Type-level tests
  • Browser boundaries
  • Page stability
  • Architecture rules
    • Overview
    • Declarative baseline
    • Unique identities
    • HTTP endpoint ownership
    • Computed purity
    • Dependency cycles
    • Mutation reactions
    • Route DI proofs
    • Route component files
    • Path boundaries
    • Exclusive links
    • Persisted identities
    • insertSelect keys
    • craftEffect and network
    • craftEffect and imperative sync
    • Interactive element names
    • Metric thresholds
    • Server-state loaders
    • Primitive loader requirements
  • Extensible architecture graph
  • Graph insights
  • Craft graph vs Nx
ix.Reactivity utilities
  • craftComputed
  • craftEffect
  • craftMethod
  • source$
  • on$
  • fromEventToSource$
  • sourceFromEvent
  • afterRecomputation
x.Internationalisation
  • Type-safe i18n
  • The catalogue
  • Tokens
  • The runtime
  • With Effect
xi.Deployment (experimental)
  • Overview
  • Manifest reference
  • Diagnostics
  • Providers
  • Alchemy provider
xii.Going further
  • SSR and hydration
  • Effect integration
  • Program operators
  • Pattern matching
  • Observability
  • AI agents
  • Temporal runtime
  1. Internationalisation
  2. Tokens

Tokens#

A token is the unit that makes a message parameter typed. It carries a name (the parameter key), a kind, a way to check the value — a guard or a schema — and a way to render it: a formatter, or a resolver that builds one from the injector. Both see the active locale.

The shipped tokens#

They are semantic, not stylistic, and every one of them formats through Intl, so the output follows the locale rather than a hand-written rule:

ts
import {
  compactNumber,
  dateLong,
  dateShort,
  dateTime,
  integer,
  money,
  number,
  percent,
  relativeTime,
} from '@craft-ts/i18n';

const price = money('price', undefined, { currency: 'EUR' });
const ratio = percent('ratio', undefined, { maximumFractionDigits: 1 });
const placedAt = dateLong('placedAt');
const lastSync = relativeTime('lastSync', undefined, { unit: 'day' });
factoryparameter typeformats as
number, integer, compactNumbernumberdecimal, no fraction, compact
percentnumber0.125 → 12.5 %
moneynumbercurrency, EUR by default
dateShort, dateLong, dateTimeDate | numberdate and date-time styles
relativeTimenumber-2 → 2 days ago

Each is a factory: factory(name, adapter?, options?). The name is what the params object will be keyed by, so the same factory serves any number of parameters — money('amount') and money('refund') are two different tokens. The adapter position takes any of three things: a type guard, a Standard Schema, or a generator that resolves the options from a service.

Options that come from a service#

Every factory — not only money — accepts a CraftTS generator in place of the adapter when its options depend on a service:

ts
const orderAmount = money('amount', function* () {
  const currency = yield* ClientCurrency();
  return { currency: currency.code, minimumFractionDigits: 2 };
});

The yielded service is part of the token's type. It is therefore propagated to the translation reader and then to the component/route DI check. A provider missing from the reachable craftComponent/route scope fails compilation. The generator runs when the message is rendered, not when the catalogue module is imported.

It must be a generator function. An arrow that returns a generator satisfies the signature but is not one, so it is rejected rather than silently installed as the value guard.

Validating and parsing with a schema#

The second argument also accepts a Standard Schema: the same contract state, query and forms already take, so a Zod, Valibot or ArkType schema written for the rest of the application drops in unchanged.

ts
const placedAt = dateLong('placedAt', z.coerce.date());

msg`Placed on ${placedAt}.`;
// the call site passes a string, the formatter receives a Date
translate('order', { placedAt: '2026-08-25T14:30:00Z' });

A schema is not only a guard: the parameter type is the schema's input and the formatter receives its output. Parsing therefore happens once, in the catalogue, instead of at every call site. An invalid value raises I18nRuntimeError with the schema's own issue messages, and an asynchronous schema is refused — a translation renders synchronously.

A project token declared with defineToken takes the same schema field, and may combine it with resolveFormatter: the parameter is parsed, the formatter is resolved from the injector.

Your own token#

Business vocabulary does not belong in a shared library. defineToken builds one, and it looks exactly like a shipped token at the call site:

ts
import { defineToken } from '@craft-ts/i18n';

type OrderStatus = 'paid' | 'pending' | 'refunded';

export const orderStatus = defineToken({
  name: 'status',
  kind: 'order-status',
  tokenId: 'app.order-status',
  // The guard is what keeps an arbitrary string out of the params type.
  validate: (value: unknown): value is OrderStatus =>
    value === 'paid' || value === 'pending' || value === 'refunded',
  format: (value: OrderStatus, context) =>
    context.locale.startsWith('fr')
      ? { paid: 'Payée', pending: 'En attente', refunded: 'Remboursée' }[value]
      : { paid: 'Paid', pending: 'Pending', refunded: 'Refunded' }[value],
});

The validate guard is what keeps an arbitrary string out of the params type: a message that interpolates this token accepts 'paid' | 'pending' | 'refunded' and nothing else. Without it, the parameter widens and the token stops earning its place. A schema field does the same job and can parse on the way in; defineToken accepts either, and may combine a schema with a resolveFormatter so the parameter is parsed and the formatter comes from the injector.

A token can also resolve its formatter from the injector rather than carry one. Here the unit system is a Craft service, so the same catalogue renders kilogrammes for one user and pounds for another:

ts
import { craftService, state } from '@craft-ts/core';
import { button, craftComponent, div, p } from '@craft-ts/component';
import {
  createI18nRuntime,
  defineCatalog,
  defineLocale,
  defineToken,
  msg,
} from '@craft-ts/i18n';

type UnitSystem = 'metric' | 'imperial';

const { Units } = craftService(
  { name: 'Units', providedIn: 'global' },
  function* () {
    yield* state('system', 'metric' as UnitSystem, ({ set }) => ({
      useImperial: () => set('imperial'),
    }));
  },
);

// No `format`: the unit does not exist until the service has answered, so the
// token declares a resolver instead of a standalone formatter.
const weight = defineToken({
  name: 'weight',
  kind: 'weight',
  resolveFormatter: function* () {
    const units = yield* Units();
    const unit = (yield* units.system()) === 'imperial' ? 'pound' : 'kilogram';
    return (value: number, context) =>
      new Intl.NumberFormat(context.locale, { style: 'unit', unit }).format(
        unit === 'pound' ? value * 2.20462 : value,
      );
  },
});

const en = defineLocale(
  'en-US',
  defineCatalog({ order: { line: msg`Shipping ${weight}.` } }),
);
const runtime = createI18nRuntime({ locales: [en] });

const ShippingLine = craftComponent('ShippingLine', {}, function* () {
  const units = yield* Units();
  // The translator re-reads whenever the unit system does.
  const translate = runtime.bind(units.system);

  return div([
    p(translate('order.line', { weight: 12 })),
    button(
      'imperial',
      { type: 'button', click: units.system.useImperial },
      'Imperial',
    ),
  ]);
});

The yielded service travels with the message: pass the reader to the template and a missing provider is a compile error, exactly as for a service yielded by the component. See DI inside a translation.

format receives the value and a context carrying locale and, when the runtime was given one, timeZone. Keep the branching on context.locale coarse — a language prefix, not a full locale match — unless you genuinely have per-region wording.

format or resolveFormatter, never both#

A token formats through exactly one of the two, and both renderers take resolveFormatter first whenever it is there:

declaredwhen the formatter is known
formatwhen the catalogue is written — formatters.money('EUR')
resolveFormatterat render time, from the injector — the client's currency

So a token with a resolver declares no format. t still renders it if the resolver yields nothing; the moment it yields a service request, the message belongs to a bound translator and its key leaves StaticTranslationKey.

percent takes a ratio, not a percentage: 0.125, not 12.5. That is Intl's convention and the token does not second-guess it.

A family of tokens#

When the same formatting rule serves several parameter names and options, defineTokenFactory builds the factory instead of the token:

ts
import { defineTokenFactory } from '@craft-ts/i18n';

// One factory, many parameter names: `duration('elapsed')`, `duration('ttl')`.
export const duration = defineTokenFactory<
  'duration',
  number,
  { readonly unit?: Intl.RelativeTimeFormatUnit }
>({
  kind: 'duration',
  format: (options) => (value: number, context) =>
    new Intl.NumberFormat(context.locale, {
      style: 'unit',
      unit: options?.unit ?? 'minute',
    }).format(value),
});

That is exactly how number, money and the rest are built; there is no privileged path for the shipped ones.

Conventionally these live in src/i18n/project-tokens.ts, which is where craft create puts them and what the generated agent skill points at.

Next#

  • The runtime — spending a catalogue built from these.

←The catalogueThe runtime→

On this page

  • The shipped tokens
  • Options that come from a service
  • Validating and parsing with a schema
  • Your own token
  • format or resolveFormatter, never both
  • A family of tokens
  • Next

@craft-ts · MIT licensed

GitHubnpm

Search