# Message catalogs (/docs/client/i18n/messages)

---
title: Message catalogs
description: A message is a plain string or an object of plural forms. This page covers both, plus the placeholder rules, what t() returns when a value is missing, and every check the factory runs.
kind: guide
package: questpie
---

A catalog is flat. `"items.count"` is one key with a dot in it, not a path into
a nested object. Nest one and the factory reads it as a plural message, then
throws because it has no `one` form.

```ts
const messages = {
	en: {
		greeting: "Hello {{name}}",
		"items.count": {
			one: "{{count}} item",
			other: "{{count}} items",
		},
	},
};
```

## Placeholders

A placeholder is a name in double braces. `{{name}}` and `{{ name }}` mean the
same thing. The name may hold letters, digits and underscores. `{{user.name}}`
has a dot. It never matches, so it stays in the output as you wrote it.

A value that is missing leaves the placeholder in place. You see the hole
instead of the word "undefined". The echo drops the spaces, so `{{ name }}`
comes back as `{{name}}`.

| Call                                 | Result           |
| ------------------------------------ | ---------------- |
| `t("greeting", { name: "Ada" })`     | `Hello Ada`      |
| `t("greeting", { name: null })`      | `Hello null`     |
| `t("greeting", { name: undefined })` | `Hello {{name}}` |
| `t("greeting", {})`                  | `Hello {{name}}` |
| `t("greeting")`                      | `Hello {{name}}` |

Only `undefined` counts as missing. Every other value goes through `String()`,
which is why `null` prints. `t()` always returns a plain string. Nothing here
parses HTML or renders a component.

## Plural forms

`one` and `other` are required. `zero`, `two`, `few` and `many` are optional.
Any other key throws.

`t()` reads `params.count`. It must be a number. Anything else is read as 1.
`Intl.PluralRules` for the current locale then picks the category. If your
object has no entry for that category, `other` is used.

Categories are per message, not per catalog, because locales disagree about
them. English needs `one` and `other`. Slovak uses `one` for 1, `few` for 2 to
4, `many` for fractions and `other` for the rest. Declare what each locale
needs.

```ts
const sk = {
	"items.count": {
		one: "{{count}} položka",
		few: "{{count}} položky",
		other: "{{count}} položiek",
	},
};
```

<Callout type="warn" title="A plural message needs a count">
	`t("items.count")` returns `{{ count }} item`. With no params there is nothing
	to interpolate, and the absent count is read as 1. Pass `{count}` every time
	you call a plural message.
</Callout>

## A key that is not in the catalog

`t()` returns the key itself. A typo shows up on the screen as `items.cuont`.
That is ugly, and that is the point.

`fallbackLocale` names the catalog `t()` reads second. It defaults to the first
entry in `locales`. Every catalog carries the same keys, though. So that second
read can never find a message the first one missed, and setting it changes no
output.

## What the factory checks

`createSimpleI18n` validates everything before it returns. Every failure throws
`TypeError`. Most messages name the locale or key at fault. The checks on
`locales` and on `messages` as a whole have nothing to name.

| Rule                                          | TypeScript | Runtime |
| --------------------------------------------- | ---------- | ------- |
| At least one locale                           | yes        | yes     |
| Locale codes are unique                       | no         | yes     |
| Each code is a valid locale, with no padding  | no         | yes     |
| `locale` is one of `locales`                  | yes        | yes     |
| `fallbackLocale` is one of `locales`          | yes        | yes     |
| Every locale has a catalog                    | yes        | yes     |
| No catalog for an undeclared locale           | yes        | yes     |
| Every catalog carries the same keys           | yes        | yes     |
| A plural message has string `one` and `other` | yes        | yes     |
| No unknown plural category                    | no         | yes     |

The runtime pass is not a duplicate. It is what catches a catalog you fetched
as JSON, or a call from plain JavaScript. The types were never there for
either.

`setLocale` is the one check that fires later. Pass it a locale you never
declared and it throws `RangeError`.

## Catalogs are copied

The factory clones every catalog and freezes the copy. Editing your source
object afterwards changes nothing the adapter reads.

There is also no method that swaps the messages out. A new catalog means a new
adapter. In React that costs one line. `I18nProvider` rebuilds its context
value when the `adapter` prop changes. Every consumer below rerenders.

## Formatters are cached

Building an `Intl` formatter costs far more than using one. Each adapter keeps
its own cache for dates, numbers, plural rules, relative times and display
names. Each holds 32 entries, keyed by locale and options, and drops the least
recently used first. A list that formats a thousand dates the same way builds
one formatter.

## The admin panel

`@questpie/admin` builds its adapter on this same factory. It fetches one
locale at a time. So it fills the missing catalogs in first, then calls the
factory. That is an Admin concern. The factory you call stays strict.
