diff --git a/.storybook/index.mdx b/.storybook/index.mdx
index 38b141c..934f046 100644
--- a/.storybook/index.mdx
+++ b/.storybook/index.mdx
@@ -48,6 +48,14 @@ declare module '@douglasneuroinformatics/libui/i18n' {
init({ translations: { common } });
```
+To require that every language be provided, rather than falling back to the default language at runtime, add:
+
+```ts
+export interface Options {
+ requireCompleteTranslations: true;
+}
+```
+
**main.tsx**
```js
diff --git a/TRANSLATIONS.md b/TRANSLATIONS.md
index d7ba341..79bc96d 100644
--- a/TRANSLATIONS.md
+++ b/TRANSLATIONS.md
@@ -16,7 +16,7 @@ The `Language` type includes `en`, `es`, and `fr` out of the box. Every leaf nod
}
```
-All language keys are optional (`{ [L in Language]?: string }`). When the active language has no translation, the translator falls back to `defaultLanguage` (defaults to `en`).
+By default, all language keys are optional (`{ [L in Language]?: string }`). When the active language has no translation, the translator falls back to `defaultLanguage` (defaults to `en`). Consumers who want every language to be mandatory can opt in — see [Requiring complete translations](#requiring-complete-translations).
## Architecture
@@ -99,6 +99,45 @@ The `LanguageToggle` component renders a dropdown from the `options` prop — on
```
+## Requiring complete translations
+
+By default a translation may omit languages and fall back at runtime. Set `requireCompleteTranslations` on `UserConfig.Options` to make every language in `LanguageOptions` mandatory:
+
+```ts
+declare module '@douglasneuroinformatics/libui/i18n' {
+ export namespace UserConfig {
+ export interface Options {
+ requireCompleteTranslations: true;
+ }
+ }
+}
+```
+
+With the flag set, both registered JSON namespaces and inline objects are checked:
+
+```ts
+// error: Property 'fr' is missing in type '{ en: string; es: string; }'
+t({ en: 'Save', es: 'Guardar' });
+```
+
+```ts
+// common.json is missing "fr" for one or more keys
+declare module '@douglasneuroinformatics/libui/i18n' {
+ export namespace UserConfig {
+ export interface Translations {
+ // error: Property 'common' ... is not assignable to 'string' index type
+ common: typeof common;
+ }
+ }
+}
+```
+
+The JSON error is reported on the offending property in your own `declare module` block, so it is unaffected by `skipLibCheck`. Note that:
+
+- Leaf **detection** stays permissive, so adding a language that `libui.json` does not yet translate never corrupts `TranslationKey`.
+- libui's own `libui` namespace is exempt from the check — it is typed directly from `libui.json` rather than through the index signature.
+- Per-language format arguments (`TranslateFormatArgs`) remain optional, since they are a formatting convenience rather than translated copy.
+
## Adding a new language
1. Add the language code to `LanguageOptions` in `src/i18n/types.ts`.
diff --git a/src/i18n/translator.ts b/src/i18n/translator.ts
index a719deb..61fe616 100644
--- a/src/i18n/translator.ts
+++ b/src/i18n/translator.ts
@@ -10,6 +10,7 @@ import type {
TranslateOptions,
TranslationKey,
Translations,
+ TranslationValue,
TranslatorType
} from './types.ts';
@@ -112,7 +113,7 @@ export class Translator implements TranslatorType {
}
@InitializedOnly
- t(target: TranslationKey | { [L in Language]?: string }, { args }: TranslateOptions = {}): string {
+ t(target: TranslationKey | TranslationValue, { args }: TranslateOptions = {}): string {
let obj: { [key: string]: string };
if (typeof target === 'string') {
obj = get(this.#config.translations, target) ?? {};
diff --git a/src/i18n/types.ts b/src/i18n/types.ts
index d2848ff..1d1c39a 100644
--- a/src/i18n/types.ts
+++ b/src/i18n/types.ts
@@ -7,7 +7,7 @@ import type { Merge, OmitIndexSignature, Primitive, Simplify } from 'type-fest';
import type libuiTranslations from './translations/libui.json';
interface TranslationsLike {
- [key: string]: TranslationsLike | { [L in Language]?: string };
+ [key: string]: TranslationsLike | TranslationValue;
}
interface DefaultLanguageOptions {
@@ -16,10 +16,32 @@ interface DefaultLanguageOptions {
fr: true;
}
+/**
+ * The shape used to *identify* a translation leaf, as opposed to a group of nested translations.
+ * This is always partial, so that key extraction is unaffected by {@link RequireCompleteTranslations}.
+ */
+type TranslationValueLike = { [L in Language]?: string };
+
export declare namespace UserConfig {
interface LanguageOptions {
[key: string]: boolean;
}
+ /**
+ * Opt-in flags, set by consumers through declaration merging. This interface is intentionally
+ * empty here: declaring a member with a default (e.g. `requireCompleteTranslations?: boolean`)
+ * would make a consumer's `requireCompleteTranslations: true` an illegal redeclaration, since
+ * merged interface members must have identical types.
+ *
+ * @example
+ * declare module '@douglasneuroinformatics/libui/i18n' {
+ * export namespace UserConfig {
+ * export interface Options {
+ * requireCompleteTranslations: true;
+ * }
+ * }
+ * }
+ */
+ interface Options {}
interface Translations extends TranslationsLike {}
}
@@ -27,6 +49,21 @@ export type LanguageOptions = OmitIndexSignature & {
libui: typeof libuiTranslations;
@@ -35,7 +72,7 @@ export type Translations = Simplify<
export type ExtractTranslationKey = Key extends string
? T[Key] extends { [key: string]: any }
- ? T[Key] extends { [K in Language]?: string }
+ ? T[Key] extends TranslationValueLike
? Key
: `${Key}.${ExtractTranslationKey}`
: `${Key}`
@@ -60,7 +97,7 @@ export type TranslateOptions = {
export interface TranslateFunction {
(key: TKey, options?: TranslateOptions): string;
- (translations: { [L in Language]?: string }, options?: TranslateOptions): string;
+ (translations: TranslationValue, options?: TranslateOptions): string;
}
export type TranslatorType = {