react-i18next icon indicating copy to clipboard operation
react-i18next copied to clipboard

Typescript safer interpolation

Open MrChoclate opened this issue 2 years ago • 2 comments

🚀 Feature Proposal

The goal here is to improve the type the TFunction so that if we have variables in our translation file, the TFunction is aware of it and help prevent missing keys or extra keys.

Motivation

Let's say we have this json file:

{
  "key1": "text",
  "key2": "text with {{var}}"
}

Then if we do

t("key1") // works
t("key1", { unknown: "fail plz" }) // fails
t("key2") // fails
t("key2", { var: "cool" }) // works

Additional context:

I am aware that is could be a dupe of https://github.com/i18next/react-i18next/issues/1423. But I don't think the lib should wait for the feature to be implemented because there is workarounds for it. In our project, we automatically type the json by generating an associated .d.ts with the proper type.

So the json in our example has the following type:

declare const $defaultExport: {
  "key1": "text",
  "key2": "text with {{var}}"
}

So the type DefaultResources = TypeOptions['resources']; has more informations and we should use that.

Proposal:

The previous definition was:

export interface TFunction<N extends Namespace = DefaultNamespace, TKPrefix = undefined> {
  <
    TKeys extends TFuncKey<N, TKPrefix> | TemplateStringsArray extends infer A ? A : never,
    TDefaultResult extends TFunctionResult | React.ReactNode = string,
    TInterpolationMap extends object = StringMap
  >(
    key: TKeys | TKeys[],
    options?: TOptions<TInterpolationMap> | string,
  ): TFuncReturn<N, TKeys, TDefaultResult, TKPrefix>;
  <
    TKeys extends TFuncKey<N, TKPrefix> | TemplateStringsArray extends infer A ? A : never,
    TDefaultResult extends TFunctionResult | React.ReactNode = string,
    TInterpolationMap extends object = StringMap
  >(
    key: TKeys | TKeys[],
    defaultValue?: string,
    options?: TOptions<TInterpolationMap> | string,
  ): TFuncReturn<N, TKeys, TDefaultResult, TKPrefix>;
}

And the new one is:

type Keys<S extends string> = S extends '' ? [] :
  S extends `${infer _}{{${infer B}}}${infer C}` ? [B, ...Keys<C>] : []

export interface TFunction<N extends Namespace = DefaultNamespace, TKPrefix = undefined> {
  <
    TKeys extends TFuncKey<N, TKPrefix> | TemplateStringsArray extends infer A ? A : never,
    TDefaultResult extends TFunctionResult | React.ReactNode = string,
  >(
    key: TKeys | TKeys[],
    ...defaultValueOrOptions: Keys<TFuncReturn<N, TKeys, TDefaultResult, TKPrefix>>[number] extends never ? ([] | [TOptionsBase | string] | [string, TOptionsBase | string]) : [TOptions<Record<Keys<TFuncReturn<N, TKeys, TDefaultResult, TKPrefix>>[number], string | number>>] | [string, TOptions<Record<Keys<TFuncReturn<N, TKeys, TDefaultResult, TKPrefix>>[number], string | number>>]
  ): TFuncReturn<N, TKeys, TDefaultResult, TKPrefix>;
}

Let's analyse the changes here:

Keys<S> is here to get the value of variables in the string. So Keys<"string"> is [] and Keys<"{{var}} foo"> is ["var"]. Keys<"{{var1}} bar {{var2}}">[number] is "var1" | "var2". Keys<"string">[number] is never.

We changed TInterpolationMap to Record<Keys<DefaultResources[N][TKeys]>[number], string | number>.

The type we want for TInterpolationMap is a map { variables: value }. To get the variables we need to use Keys<S> on the string value. The string value is the current implementation of TFuncReturn<N, TKeys, TDefaultResult, TKPrefix>. If we change the TFuncReturn value to interpolate the result like the Interpolate in this SO , this need to change.

We directly use Record<Keys<TFuncReturn<N, TKeys, TDefaultResult, TKPrefix>>[number], string | number> instead of TInterpolationMap extends to avoid the possible extra keys.

Since now the argument of the TFunction is optional based on the generics, I did not find a better way that to use spread operator. (SO) leading to a less readable type.

Let's analyse it:

...defaultValueOrOptions: Keys<TFuncReturn<N, TKeys, TDefaultResult, TKPrefix>>[number] extends never ? ([] | [TOptionsBase | string] | [string, TOptionsBase]) : [TOptions<Record<Keys<TFuncReturn<N, TKeys, TDefaultResult, TKPrefix>>[number], string | number>> | string] | [string, TOptions<Record<Keys<TFuncReturn<N, TKeys, TDefaultResult, TKPrefix>>[number], string | number>> | string]

Keys<TFuncReturn<N, TKeys, TDefaultResult, TKPrefix>>[number] extends never is a check to know if there is any variables. If there is no variables:

defaultValueOrOptions can be

  • no args: []
  • one arg: the base options with no extra key or default value.
  • two args: the default value and the base options with no extra keys.

If there is variables, the defaultValueOrOptions can be

  • one arg: the base options with the variables map (TOptions<Record<Keys<TFuncReturn<N, TKeys, TDefaultResult, TKPrefix>>[number], string | number>>)
  • two args: the default value and the options with the variables map.

I did not test what this code will result if the json is not typed with real string. But maybe it could be something like:

type Keys<S extends string> = string extends S ? string[] : S extends '' ? [] :
  S extends `${infer _}{{${infer B}}}${infer C}` ? [B, ...Keys<C>] : []

In our project, we don't use nested keys so I did not test that.

Please don't hesitate to point out any mistakes or improvement. I would love to have this changes integrated or have a better support with a fully typed json.

MrChoclate avatar Aug 24 '22 10:08 MrChoclate

Great proposal ! I would just add that the variable interpolation token {{/}} can be customized in the config interpolation entry using prefix/suffix keys so it should be also supported in the typing.

Kayyow avatar Sep 07 '22 16:09 Kayyow

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

stale[bot] avatar Sep 20 '22 19:09 stale[bot]

should this issue be reopened? Looks like very cool proposal!

yekver avatar Oct 05 '22 13:10 yekver

should this issue be reopened? Looks like very cool proposal!

@pedrodurek ok if we open this again?

adrai avatar Oct 05 '22 14:10 adrai

@adrai yes, we should keep it open!

pedrodurek avatar Oct 06 '22 07:10 pedrodurek

This would be really cool.

Rednegniw avatar Dec 14 '22 10:12 Rednegniw

I would like to contribute to this. Was trying to implement this myself, but overwriting type of t returned from the hook is the pain in the ass.

gloomweaver avatar Feb 08 '23 18:02 gloomweaver

@pedrodurek is currently working on a different TS approach

adrai avatar Feb 08 '23 18:02 adrai

I'm curretly running into problems, when updating @types/react to the latest version, as these now get checked and will fail the type check.

See https://github.com/flathub/website/pull/843

razzeee avatar Feb 08 '23 20:02 razzeee

Please try with i18next v23.0.1 and react-i18next v13.0.0

adrai avatar Jun 15 '23 09:06 adrai

should work.... proof here: https://github.com/locize/i18next-typescript-examples/blob/main/2/index.ts

https://github.com/i18next/i18next/issues/1953#issuecomment-1601317429

adrai avatar Jun 21 '23 18:06 adrai

Should this also work with <Trans> or what do I need to do to allow that? https://github.com/flathub/website/actions/runs/5349795927/jobs/9701520404?pr=843

razzeee avatar Jun 22 '23 20:06 razzeee

@razzeee I don't think that is related to this interpolation issue... please isolate your problem and create a minimal reproducible example repo and open a new issue.

I suspect having something like this would also error the same way (also without Trans):

<span>hello {{ id: appId }} you</span>

adrai avatar Jun 22 '23 20:06 adrai