diff --git a/index.d.ts b/index.d.ts index e7b769d32..7ce5d34e0 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,21 +1,126 @@ -type Omit = Pick>; +// Helpers type MergeBy = Omit & K; +type StringMap = { [key: string]: any }; +type UnionToIntersection = (U extends any ? (k: U) => void : never) extends (k: infer I) => void + ? I + : never; +type LastOf = UnionToIntersection T : never> extends () => infer R + ? R + : never; -export interface FallbackLngObjList { - [language: string]: readonly string[]; -} +/** + * This interface can be augmented by users to add types to `i18next` default TypeOptions. + */ +export interface CustomTypeOptions {} -export type FallbackLng = - | string - | readonly string[] - | FallbackLngObjList - | ((code: string) => string | readonly string[] | FallbackLngObjList); +/** + * This interface can be augmented by users to add types to `i18next` default PluginOptions. + * + * Usage: + * ```ts + * // react-i18next.d.ts + * import 'react-i18next'; + * declare module 'react-i18next' { + * interface CustomTypeOptions { + * defaultNS: 'custom'; + * returnNull: false; + * returnEmptyString: false; + * nsSeparator: ':'; + * keySeparator: '.'; + * jsonFormat: 'v4'; + * allowObjectInHTMLChildren: false; + * resources: { + * custom: { + * foo: 'foo'; + * }; + * }; + * } + * } + * ``` + */ +export interface CustomPluginOptions {} + +export type TypeOptions = MergeBy< + { + /** + * Allows null values as valid translation + */ + returnNull: true; + + /** + * Allows empty string as valid translation + */ + returnEmptyString: true; + + /** + * Char to separate keys + */ + keySeparator: '.'; + + /** + * Char to split namespace from key + */ + nsSeparator: ':'; + + /** + * Default namespace used if not passed to translation function + */ + defaultNS: 'translation'; + + /** + * Json Format Version - V4 allows plural suffixes + */ + jsonFormat: 'v4'; -export type FormatFunction = ( + /** + * Resources to initialize with + */ + resources: object; + + /** + * Flag that allows HTML elements to receive objects. This is only useful for React applications + * where you pass objects to HTML elements so they can be replaced to their respective interpolation + * values (mostly with Trans component) + */ + allowObjectInHTMLChildren: false; + }, + CustomTypeOptions +>; + +export type PluginOptions = MergeBy< + { + /** + * Options for language detection - check documentation of plugin + * @default undefined + */ + detection?: object; + + /** + * Options for backend - check documentation of plugin + * @default undefined + */ + backend?: object; + + /** + * Options for cache layer - check documentation of plugin + * @default undefined + */ + cache?: object; + + /** + * Options for i18n message format - check documentation of plugin + * @default undefined + */ + i18nFormat?: object; + }, + CustomPluginOptions +>; + +type FormatFunction = ( value: any, format?: string, lng?: string, - options?: InterpolationOptions & { [key: string]: any }, + options?: InterpolationOptions & StringMap, ) => string; export interface InterpolationOptions { @@ -124,6 +229,16 @@ export interface InterpolationOptions { skipOnVariables?: boolean; } +export interface FallbackLngObjList { + [language: string]: readonly string[]; +} + +export type FallbackLng = + | string + | readonly string[] + | FallbackLngObjList + | ((code: string) => string | readonly string[] | FallbackLngObjList); + export interface ReactOptions { /** * Set it to fallback to let passed namespaces to translated hoc act as fallbacks @@ -191,38 +306,7 @@ export interface ReactOptions { unescape?(str: string): string; } -/** - * This interface can be augmented by users to add types to `i18next` default PluginOptions. - */ -export interface PluginOptions {} - -interface DefaultPluginOptions { - /** - * Options for language detection - check documentation of plugin - * @default undefined - */ - detection?: object; - - /** - * Options for backend - check documentation of plugin - * @default undefined - */ - backend?: object; - - /** - * Options for cache layer - check documentation of plugin - * @default undefined - */ - cache?: object; - - /** - * Options for i18n message format - check documentation of plugin - * @default undefined - */ - i18nFormat?: object; -} - -export interface InitOptions extends MergeBy { +export interface InitOptions extends PluginOptions { /** * Logs info level to console output. Helps finding issues with loading not working. * @default false @@ -627,37 +711,87 @@ export interface TOptionsBase { interpolation?: InterpolationOptions; } -/** - * indexer that is open to any value - */ -export type StringMap = { [key: string]: any }; - /** * Options that allow open ended values for interpolation unless type is provided. */ export type TOptions = TOptionsBase & TInterpolationMap; -export type Callback = (error: any, t: TFunction) => void; - -/** - * Uses similar args as the t function and returns true if a key exists. - */ -export interface ExistsFunction< - TKeys extends string = string, - TInterpolationMap extends object = StringMap, -> { - (key: TKeys | TKeys[], options?: TOptions): boolean; -} - -export interface WithT { +type FallbackOrNS = [T] extends [never] ? F : T; + +type Resources = TypeOptions['resources']; +type DefaultNamespace = TypeOptions['defaultNS']; + +export type Namespace> = T | T[]; + +type PluralSuffix = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other'; + +type WithOrWithoutPlural = TypeOptions['jsonFormat'] extends 'v4' + ? K extends `${infer B}_${PluralSuffix}` + ? B | K + : K + : K; + +// Normalize single namespace +export type KeysWithSeparator = `${K1 & + string}${S}${K2 & string}`; +type KeysWithSeparator2 = KeysWithSeparator>; +type Normalize2 = K extends keyof T + ? T[K] extends StringMap + ? T[K] extends readonly any[] + ? + | KeysWithSeparator2> + | KeysWithSeparator2> + : + | KeysWithSeparator> + | KeysWithSeparator> + : never + : never; +type Normalize = WithOrWithoutPlural | Normalize2; + +// Normalize multiple namespaces +type KeyWithNSSeparator = `${N & + string}${S}${K & string}`; +type NormalizeMulti> = L extends U + ? KeyWithNSSeparator> | NormalizeMulti> + : never; + +// Normalize single namespace with key prefix +type NormalizeWithKeyPrefix< + T, + K, + S extends string = TypeOptions['keySeparator'], +> = K extends `${infer K1}${S}${infer K2}` + ? K1 extends keyof T + ? NormalizeWithKeyPrefix + : never + : K extends keyof T + ? T[K] extends string + ? never + : Normalize + : never; + +export type KeyPrefix = + | (N extends keyof Resources ? Normalize : string) + | undefined; + +export type TFuncKey< + N extends Namespace = DefaultNamespace, + TKPrefix = undefined, + T = Resources, +> = N extends (keyof T)[] | Readonly<(keyof T)[]> + ? NormalizeMulti + : N extends keyof T + ? TKPrefix extends undefined + ? Normalize + : NormalizeWithKeyPrefix + : string; + +export interface WithT { // Expose parameterized t in the i18next interface hierarchy - t: TFunction; + t: TFunction; } -/** - * Object returned from t() function when passed returnDetails: true option. - */ export type TFunctionDetailedResult = { /** * The plain used key @@ -680,65 +814,102 @@ export type TFunctionDetailedResult = { */ usedNS: string; }; -export type TFunctionResult = + +type TypeOptionsFallback = Option extends false + ? TranslationValue extends MatchingValue + ? string + : TranslationValue + : TranslationValue; + +/** + * Checks if user has enabled `returnEmptyString` and `returnNull` options to retrieve correct values. + */ +export type NormalizeByTypeOptions< + TranslationValue, + R = TypeOptionsFallback, +> = TypeOptionsFallback; + +type StringIfPlural = TypeOptions['jsonFormat'] extends 'v4' + ? T extends `${string}_${PluralSuffix}` + ? string + : never + : never; + +type NormalizeReturn< + T, + V, + S extends string | false = TypeOptions['keySeparator'], +> = V extends keyof T + ? NormalizeByTypeOptions + : S extends false + ? V + : V extends `${infer K}${S}${infer R}` + ? K extends keyof T + ? NormalizeReturn + : never + : StringIfPlural; + +type NormalizeMultiReturn = V extends `${infer N}:${infer R}` + ? N extends keyof T + ? NormalizeReturn + : never + : never; + +export type DefaultTFuncReturn = | string | object | TFunctionDetailedResult | Array | undefined | null; -export type TFunctionKeys = string | TemplateStringsArray; -export interface TFunction { - // basic usage - < - TResult extends TFunctionResult = string, - TKeys extends TFunctionKeys = string, - TInterpolationMap extends object = StringMap, - >( - key: TKeys | TKeys[], - ): TResult; + +export type TFuncReturn< + N, + TKeys, + TDefaultResult, + TKPrefix = undefined, + T = Resources, +> = N extends (keyof T)[] + ? NormalizeMultiReturn + : N extends keyof T + ? TKPrefix extends undefined + ? NormalizeReturn + : NormalizeReturn> + : TDefaultResult; + +export interface TFunction { < - TResult extends TFunctionResult = TFunctionDetailedResult, - TKeys extends TFunctionKeys = string, + TKeys extends TFuncKey | TemplateStringsArray extends infer A ? A : never, + TDefaultResult extends DefaultTFuncReturn, TInterpolationMap extends object = StringMap, >( key: TKeys | TKeys[], - options?: TOptions & { returnDetails: true; returnObjects: true }, - ): TResult; + ): TFuncReturn; < - TResult extends TFunctionResult = TFunctionDetailedResult, - TKeys extends TFunctionKeys = string, + TKeys extends TFuncKey | TemplateStringsArray extends infer A ? A : never, + TDefaultResult extends DefaultTFuncReturn, TInterpolationMap extends object = StringMap, >( key: TKeys | TKeys[], options?: TOptions & { returnDetails: true }, - ): TResult; - < - TResult extends TFunctionResult = object, - TKeys extends TFunctionKeys = string, - TInterpolationMap extends object = StringMap, - >( - key: TKeys | TKeys[], - options?: TOptions & { returnObjects: true }, - ): TResult; + ): TFunctionDetailedResult>; < - TResult extends TFunctionResult = string, - TKeys extends TFunctionKeys = string, + TKeys extends TFuncKey | TemplateStringsArray extends infer A ? A : never, + TDefaultResult extends DefaultTFuncReturn, TInterpolationMap extends object = StringMap, >( key: TKeys | TKeys[], - options?: TOptions | string, - ): TResult; - // overloaded usage + options?: TOptions, + ): TFuncReturn; < - TResult extends TFunctionResult = string, - TKeys extends TFunctionKeys = string, + TKeys extends TFuncKey | TemplateStringsArray extends infer A ? A : never, + TDefaultResult extends DefaultTFuncReturn, TInterpolationMap extends object = StringMap, >( key: TKeys | TKeys[], defaultValue?: string, options?: TOptions | string, - ): TResult; + ): TFuncReturn; } export interface Resource { @@ -920,14 +1091,25 @@ export interface Modules { export interface Newable { new (...args: any[]): T; } - export interface NewableModule extends Newable { type: T['type']; } +type Callback = (error: any, t: TFunction) => void; + +/** + * Uses similar args as the t function and returns true if a key exists. + */ +export interface ExistsFunction< + TKeys extends string = string, + TInterpolationMap extends object = StringMap, +> { + (key: TKeys | TKeys[], options?: TOptions): boolean; +} + export interface i18n { // Expose parameterized t in the i18next interface hierarchy - t: TFunction; + t: TFunction[]>; /** * The default of the i18next module is an i18next instance ready to be initialized by calling init. diff --git a/package.json b/package.json index be6532772..a8f49ff1f 100644 --- a/package.json +++ b/package.json @@ -98,15 +98,16 @@ "rollup-plugin-terser": "^4.0.4", "sinon": "11.1.2", "tslint": "^5.12.1", - "typescript": "^3.6.4", + "typescript": "^4.7.4", "watchify": "3.9.0" }, "scripts": { - "pretest": "npm run test:typescript && npm run test:typescript:noninterop", + "pretest": "npm run test:typescript && npm run test:custom-typescript && npm run test:typescript:noninterop", "test": "npm run test:new && npm run test:compat", "test:new": "karma start karma.conf.js --singleRun", "test:compat": "karma start karma.backward.conf.js --singleRun", "test:typescript": "tslint --project tsconfig.json", + "test:custom-typescript": "tslint --project test/typescript/custom-types/tsconfig.json", "test:typescript:noninterop": "tslint --project tsconfig.nonEsModuleInterop.json", "tdd": "karma start karma.conf.js", "tdd:compat": "karma start karma.backward.conf.js", diff --git a/test/typescript/custom-types/i18next.d.ts b/test/typescript/custom-types/i18next.d.ts new file mode 100644 index 000000000..1a5b39149 --- /dev/null +++ b/test/typescript/custom-types/i18next.d.ts @@ -0,0 +1,31 @@ +import 'i18next'; + +declare module 'i18next' { + interface CustomTypeOptions { + defaultNS: 'custom'; + resources: { + custom: { + foo: 'foo'; + bar: 'bar'; + }; + alternate: { + baz: 'baz'; + foobar: { + barfoo: 'barfoo'; + deep: { + deeper: { + deeeeeper: 'foobar'; + }; + }; + }; + }; + plurals: { + foo_zero: 'foo'; + foo_one: 'foo'; + foo_two: 'foo'; + foo_many: 'foo'; + foo_other: 'foo'; + }; + }; + } +} diff --git a/test/typescript/custom-types/t.test.ts b/test/typescript/custom-types/t.test.ts new file mode 100644 index 000000000..b31f16acd --- /dev/null +++ b/test/typescript/custom-types/t.test.ts @@ -0,0 +1,47 @@ +import i18next, { TFunction } from 'i18next'; + +function defaultNamespaceUsage(t: TFunction) { + t('bar'); + t('foo'); +} + +function namedDefaultNamespaceUsage(t: TFunction<'alternate'>) { + t('foobar.barfoo'); + t('foobar.deep.deeper.deeeeeper'); + t('foobar.deep.deeper').deeeeeper; +} + +function arrayNamespace(t: TFunction<['custom', 'alternate']>) { + t('alternate:baz'); + t('alternate:foobar.deep').deeper.deeeeeper; + t('custom:bar'); +} + +// @ts-expect-error +function expectErrorWhenNamespaceDoesNotExist(t: TFunction<'foo'>) {} + +function expectErrorWhenKeyNotInNamespace(t: TFunction<'alternate'>) { + // @ts-expect-error + t('bar'); +} + +function i18nextTUsage() { + i18next.t('alternate:foobar.barfoo'); + i18next.t('alternate:foobar.deep').deeper.deeeeeper; + i18next.t('custom:bar'); +} + +function expectErrorWhenInvalidKeyWithI18nextT() { + // @ts-expect-error + i18next.t('custom:test'); +} + +function expectErrorWhenInvalidNamespaceWithI18nextT() { + // @ts-expect-error + i18next.t('test:bar'); +} + +function i18nextTPluralsUsage() { + i18next.t('plurals:foo', { count: 1 }); + i18next.t('plurals:foo_many', { count: 10 }); +} diff --git a/test/typescript/custom-types/tsconfig.json b/test/typescript/custom-types/tsconfig.json new file mode 100644 index 000000000..6c88073b9 --- /dev/null +++ b/test/typescript/custom-types/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../../tsconfig.json", + "include": ["./**/*"], + "exclude": [] +} diff --git a/test/typescript/t.test.ts b/test/typescript/t.test.ts index 2ef9aba81..6c5fc85b0 100644 --- a/test/typescript/t.test.ts +++ b/test/typescript/t.test.ts @@ -16,9 +16,9 @@ function overloadedUsage(t: TFunction) { function returnCasts(t: TFunction) { const s: string = t('friend'); // same as const s2: string = t`friend`; - const o: object = t('friend'); - const sa: string[] = t('friend'); - const oa: object[] = t('friend'); + const o: object = t('friend'); + const sa: string[] = t('friend'); + const oa: object[] = t('friend'); } function defautValue(t: TFunction) { @@ -134,18 +134,18 @@ function interpolation(t: TFunction) { const resolved = t('key', { returnDetails: true }); resolved.res; - resolved.res.substring(2, 1); + if (typeof resolved.res === 'string') resolved.res.substring(2, 1); resolved.usedKey; resolved.exactUsedKey; resolved.usedNS; resolved.usedLng; const r2 = t('keyTwo', { returnDetails: false }); - r2.substring(0, 2); // make sure it is a string + if (typeof r2 === 'string') r2.substring(0, 2); // make sure it is a string const r3 = t('keyThree'); - r3.substring(0, 2); // make sure it is a string + if (typeof r3 === 'string') r3.substring(0, 2); // make sure it is a string const r4 = t('keyTwo', { ns: 'whatever' }); - r4.substring(0, 2); // make sure it is a string + if (typeof r4 === 'string') r4.substring(0, 2); // make sure it is a string t('arrayJoinWithInterpolation', { myVar: 'interpolate', diff --git a/tsconfig.json b/tsconfig.json index c1fcaa4ff..cf72775aa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,5 +15,5 @@ "allowSyntheticDefaultImports": true }, "include": ["./index.d.ts", "./test/**/*.ts*"], - "exclude": ["test/typescript/nonEsModuleInterop/**/*.ts"] + "exclude": ["test/typescript/nonEsModuleInterop/**/*.ts", "test/typescript/custom-types"] }