verbatra types
Generate TypeScript declarations for your catalog keys and their message arguments, without calling a provider or reading an API key.
Available from 0.11.0
A misspelled key is a runtime lookup failure. A forgotten interpolation argument is a placeholder rendered literally on screen. types turns both into compile errors: it reads your source catalog through the configured format adapter and writes a declaration file holding a union of every key, and per key the arguments its message takes. It spends nothing: no provider is constructed, no network request is made and no API key is read, so it works on a fresh checkout before any key exists.
It reads one file (your source locale catalog) and writes one file (the declaration). It never touches a target locale file.
Synopsis
verbatra types [flags]Flags
| Flag | Argument | Default | Effect |
|---|---|---|---|
--cwd | <path> | current directory | resolve config and locale files from this directory |
--config | <path> | search for one | load this config file instead of searching for one |
--out | <path> | verbatra-types.d.ts | where to write the declaration, relative to the working directory and inside it |
--check | none | off | report whether the committed declaration is still current, writing nothing |
--json | none | off | print one JSON envelope on stdout carrying the result under result |
What it generates
Given a locales/en.json like this:
{
"app": { "title": "Verbatra" },
"greeting": "Hello {{name}}",
"cart": { "item_one": "{{count}} item", "item_other": "{{count}} items" }
}you get:
// Generated by verbatra from locales/en.json (i18next-json). Do not edit by hand.
// Re-run `verbatra types` after the source catalog changes.
/** An argument whose type the source catalog does not record. */
export type VerbatraArgument = string | number;
/** The arguments of a message that takes none: passing any argument is a type error. */
export type VerbatraNoArguments = Record<string, never>;
/**
* The arguments of a message verbatra could not analyse, so nothing is claimed about them.
* Every key carrying this type is listed by `verbatra types` when it generates this file.
*/
export type VerbatraUnknownArguments = Record<string, VerbatraArgument>;
/** Every key in the source catalog, mapped to the arguments its message takes. */
export interface VerbatraMessages {
"app.title": VerbatraNoArguments;
"greeting": { readonly "name": VerbatraArgument };
"cart.item_one": { readonly "count": VerbatraArgument };
"cart.item_other": { readonly "count": VerbatraArgument };
}
/** Every key in the source catalog. */
export type VerbatraMessageKey = keyof VerbatraMessages;
/** The arguments the message at `Key` takes. */
export type VerbatraMessageArguments<Key extends VerbatraMessageKey> =
VerbatraMessages[Key];
/** The keys the source catalog marks as carrying plural forms. */
export type VerbatraPluralMessageKey = "cart.item_one" | "cart.item_other";Type your own translation function against it and the compiler does the rest:
import type { VerbatraMessageArguments, VerbatraMessageKey } from "./verbatra-types.js";
declare function t<Key extends VerbatraMessageKey>(
key: Key,
args: VerbatraMessageArguments<Key>,
): string;
t("greeting", { name: "Ada" }); // fine
t("greting", { name: "Ada" }); // error: not a key in the catalog
t("greeting", {}); // error: the name argument is required
t("app.title", { name: "Ada" }); // error: this message takes no argumentWhat it will and will not claim
The keys are exactly what the format adapter already produced when it read the catalog, in document order, which is why the same catalog always produces the same bytes. For most formats the arguments come from the placeholder tokens the adapter extracted. For the two ICU message formats, next-intl-json and arb, each message is read with the same ICU parser the adapter uses, so an argument only some select or plural branches use is still declared, and each argument is typed by how the message formats it. Nothing is guessed at.
| Your message | What is declared |
|---|---|
| no placeholders | VerbatraNoArguments, so passing anything is a type error |
named placeholders ({{name}}, {name}, %(name)s) | an object shape with those names |
numbered or anonymous placeholders ({0}, %@, %1$d) | a readonly tuple, one position per argument |
an argument only some select or plural branches use (next-intl-json, arb) | an optional member, name?:, or for a numbered argument an optional tuple position ([number, VerbatraArgument?]); one every branch uses stays required |
an ICU number, plural or selectordinal argument ({count, number}, {count, plural, ...}) in next-intl-json or arb | number |
an ICU date or time argument ({d, date, short}) in next-intl-json or arb | Date | number |
an ICU select argument ({gender, select, ...}) in next-intl-json or arb | string |
an i18next number formatter ({{count, number}}) in i18next-json, ngx-translate-json or yaml | number |
an i18next datetime formatter ({{d, datetime}}) in the same three formats | Date | number |
an i18next unescaped interpolation ({{- name}}) | the name behind the prefix, name |
a MessageFormat number, plural or choice argument ({0,number}) in properties | number |
a numeric printf conversion (%d, %1$d) | number |
a printf string conversion (%s, %(name)s) | string |
any other placeholder, including a plain ICU argument ({name}) | VerbatraArgument, an alias for string | number |
invalid message syntax, which only next-intl-json and arb check for | VerbatraUnknownArguments, and the key is named in the output |
| a message that both names and numbers its arguments | VerbatraUnknownArguments, and the key is named in the output |
A tuple can only leave out positions at its end, so a numbered argument only some branches use is declared optional only when every position after it is optional too. In {0, select, a {{1}} other {x}} {2}, position 1 stays required, because the required position 2 follows it.
Argument names come from the placeholder tokens the adapter extracted, so a format whose placeholders carry no name (Apple .strings, Android strings.xml, positional gettext conversions) gets a tuple rather than invented names. Argument types come only from what your catalog actually records: how an ICU message formats an argument, or the formatter or conversion a placeholder names. Where a format names an argument without saying what may be passed for it, verbatra declares string | number rather than asserting a type the catalog never carried. A name used more than once with different types is declared as the union of everything each use accepts: {d, date, short} next to a plain {d} becomes Date | number | string, and {n, plural, ...} next to a plain {n} becomes string | number. Nothing is ever declared as any.
Keys are always emitted as quoted string literals, so a key carrying a dot, a reserved word, a leading digit, a quote, or nothing at all is declared verbatim rather than dropped or re-split. A key the adapter reported as excluded from translation (a stray non-string leaf, an Android translatable="false" resource) is never declared, and is named in the output so you can see what was left out.
A key whose own name contains a dot
The declared key is spelled the way verbatra itself spells the key, the same form it uses in verbatra.lock.json, and not a lookup syntax your i18n library understands. A JSON catalog holding {"a.b": "..."} carries one key spelled a\.b, deliberately distinct from the nested path a.b, so that escaped spelling is what the declaration declares:
export interface VerbatraMessages {
"a\\.b": VerbatraNoArguments;
}Do not expect a runtime to resolve that spelling. i18next, for example, has no escape for its key separator, so t("a\\.b") is not how it looks up a key with a literal dot. If your catalog carries such keys, configure the runtime so the dot is not a separator (for i18next, set keySeparator to a different character, or to false for a flat catalog), and map the declared key to the key your runtime looks up in your own typed wrapper.
The one case where the declaration is wrong rather than merely silent
Only next-intl-json and arb validate message syntax. For every other format, a value that is broken rather than merely plain ("Hello {name", with the closing brace missing) yields no placeholder token at all, so the message is declared as taking no arguments, and passing the argument it really wants is then a compile error. Nothing reports it, because nothing detected it. The fix is the catalog, not the declaration.
Plural keys
Nothing is collapsed. i18next carries plural as a key suffix, so cart.item_one and cart.item_other are declared as the two separate keys they really are, because those are the two keys your code actually looks up. VerbatraPluralMessageKey lists whichever keys the adapter marked as carrying plural forms, so you can narrow to them if you want to.
Where the declaration goes
By default it lands at verbatra-types.d.ts in your project root. Commit it. It is a checked-in artifact, not local scratch, for two reasons: a teammate or a CI job type-checks against it without having to run verbatra first, and --check needs a committed file to compare against. Nothing adds it to .gitignore.
Pass --out to put it where your app already imports from, for example --out src/generated/messages.d.ts. Missing directories are created. An output path is refused with TYPES_OUTPUT_CONFLICT when it:
- names no file,
- is absolute,
- climbs out of the working directory with
.., - is not a TypeScript file, meaning its name does not end in
.ts,.mtsor.cts, - names a configured locale file, source or target, so generation can never overwrite a catalog,
- is the lock file,
verbatra.lock.json, - is the translation-memory cache,
verbatra.cache.json, - is a file verbatra searches for its configuration:
package.json,.verbatrarc,.verbatrarc.json,.verbatrarc.yaml,.verbatrarc.yml,.verbatrarc.js,.verbatrarc.cjs,.verbatrarc.ts,verbatra.config.js,verbatra.config.cjsorverbatra.config.ts, - is the config file the run actually loaded, including one you named with
--config, - is an existing file that does not begin with the
// Generated by verbatra fromheader line verbatra writes at the top of every declaration (a leading byte order mark and blank lines are ignored), or is too large to verify.
File names are compared regardless of case, so Verbatra.Config.ts is refused as well. All but the last are refused before anything is read or written. The last is checked only by a run that would write, and it leaves your file untouched: pick another --out, or delete the file if it really is an old declaration. --check never refuses an existing file; it only compares.
Keeping it honest in CI
--check writes nothing and exits 1 when the committed declaration no longer matches what a fresh generation would produce. The comparison is byte for byte, with no reformatting, so it catches a key added to the catalog and never regenerated:
- run: npx verbatra types --checkSet one thing up before you rely on it. The declaration is always written with line-feed endings and --check compares exact bytes, so a checkout that rewrites line endings leaves it permanently out of date with no way to fix it by re-running. Pin the endings in .gitattributes:
verbatra-types.d.ts text eol=lfRun it beside verbatra check in the same job. check catches locales drifting behind the source; types --check catches your types drifting behind it.
Examples
# write verbatra-types.d.ts from the source catalog
verbatra types
# write it where the app already imports from
verbatra types --out src/generated/messages.d.ts
# CI gate: exit 1 if the committed declaration is stale
verbatra types --check
# machine-readable result for a script
verbatra types --jsonA run looks like this:
verbatra types
214 keys declared, 63 of them taking arguments, from locales/en.json
arguments not determined (1):
legal.notice invalid-message-syntax
excluded by the adapter (1): meta.version
wrote /app/verbatra-types.d.tsRun it again without changing the catalog and it rewrites nothing:
verbatra types
214 keys declared, 63 of them taking arguments, from locales/en.json
unchanged /app/verbatra-types.d.tsAnd in check mode, once the catalog has moved on:
$ verbatra types --check
verbatra types
215 keys declared, 63 of them taking arguments, from locales/en.json
/app/verbatra-types.d.ts is out of date, re-run verbatra typesExit codes
| Code | Meaning |
|---|---|
0 | the declaration was generated, whether or not the file changed; or --check found it current |
1 | --check found the committed declaration out of date |
2 | could not run: a usage error, a config or source problem, or a refused output path |
Related
verbatra extractis the other half of the loop: it adds keys your code uses to the catalog, andtypesthen declares them.verbatra checkis the CI gate for locale drift, next to this one for type drift.- Formats lists the placeholder syntax each format uses, which is what the argument names come from.
- CI and exit codes covers the JSON envelope and the exit-code contract.