Skip to content
loclizr
loclizr.config.ts
import { defineConfig } from 'loclizr'
export default defineConfig({
locales: ['en', 'de', 'de-AT'],
sourceLocale: 'en',
groups: { errors: 'errors' },
})

Looking up an API error code as m['errors_' + code] loses the types and hides the call from the usage scan. A group gives a typed record and key union.

  1. Map a group name to a key prefix under groups, as above. It matches on a dot boundary: errors captures errors.forbidden, never errorsforbidden.

  2. loclizr build writes groups.js and groups.d.ts beside the barrel.

  3. src/Problems.tsx
    import { errors, type ErrorsKey } from './loclizr/groups'
    const problems: readonly ErrorsKey[] = ['forbidden', 'not_found', 'rate_limited']
    export function Problems({ seconds }: { seconds: number }) {
    return (
    <ul>
    {problems.map((code) => (
    <li key={code}>{errors[code]({ seconds })}</li>
    ))}
    </ul>
    )
    }
src/loclizr/groups.js
import { errors_forbidden, errors_not_found, errors_rate_limited } from './messages/errors.js'
export const errors = /*#__PURE__*/ Object.freeze({
__proto__: null,
forbidden: errors_forbidden,
not_found: errors_not_found,
rate_limited: errors_rate_limited,
})
src/loclizr/groups.d.ts
export type ErrorsKey = 'forbidden' | 'not_found' | 'rate_limited'
export interface ErrorsArgs {
forbidden: EmptyArgs
not_found: EmptyArgs
rate_limited: { seconds: number }
}
export declare const errors: Readonly<{
[K in ErrorsKey]: (args: ErrorsArgs[K], opts?: MessageOptions) => string
}>
  • Each member is the key minus prefix and dot.
  • Null prototype: a member named constructor cannot reach Object.prototype.
  • Type names are the group name in PascalCase plus Key and Args: nav.main gives NavMainKey.
Key Typing
Literal, errors.rate_limited(...) Exact: { seconds: 30 } compiles, { nope: 1 } does not.
Union, errors[code](...) Every argument any member takes is required.

Calling a union of signatures intersects their parameters, so errors[code]() and errors[code]({}) both fail on seconds. Sound, but one new member taking { retryAt } breaks every existing dynamic call site.

Members that disagree warn. A group that mixes them on purpose turns the rule off, as the example app does:

loclizr.config.ts
import { defineConfig } from 'loclizr'
export default defineConfig({
locales: ['en', 'de', 'de-AT'],
sourceLocale: 'en',
groups: { errors: 'errors' },
severity: { 'group-args-heterogeneous': 'off' },
})

With the rule on, the same build prints:

warn LZ4006 group-args-heterogeneous
The group "errors" has members with different argument sets, so every dynamic call site must pass the union: seconds.
errors.forbidden takes no arguments
errors.not_found takes no arguments
errors.rate_limited takes seconds
fix Split the group by argument shape, or give the odd members bare {x} arguments.
error LZ4004 group-empty
The group "nope" matched no keys under the prefix "nope".
fix A prefix matches on a dot boundary, so "nope" captures "nope.forbidden" and never "nopeforbidden". Check the prefix.

A prefix that matches nothing is an error. The build still emits NopeKey = never, so every call through it fails typecheck too.

  • Using a group retains all its members; an untouched group drops like any unused export.
  • The barrel does not re-export groups.js: only an app importing it pays. Messages called by name stay individually tree-shaken.
  • The usage scan counts errors.forbidden(...) for that message and errors[code](...) for every member, so a message reached only dynamically never reads as unused.

A CMS title or user-typed text is not a message and belongs outside the catalog. A runtime format() for it would ship an ICU parser to the browser; it is deferred (Limits).