-
-
Notifications
You must be signed in to change notification settings - Fork 1k
fix trans component type safe #1881
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 12 commits
5196c4d
06cc0e8
08b089b
1e98bfa
72e1a00
ef82835
389d83e
bc7e1af
13c2869
320c5f3
494a6d7
578fdff
d1735d5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
codomposer marked this conversation as resolved.
Show resolved
Hide resolved
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Testing this in a real app: There is an issue with import { Trans } from "react-i18next";
export function TranCmp() {
const someCondition = true;
return (
<div>
<Trans
ns="ns1"
i18nKey={($) => $.food}
context="pizza"
values={{ test: "a" }}
/>
<Trans
ns="ns1"
i18nKey={($) => $.food}
context={someCondition ? "pizza" : undefined}
values={{ test: "a" }}
/>
</div>
);
}with this translation file: {
"food": "food",
"food_pizza": "pizza"
}
You can find the code here: https://github.com/marcalexiei/i18next-playground/tree/main/examples/react-trans-component-type-safe-selector
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,8 @@ import type { | |
| TypeOptions, | ||
| TOptions, | ||
| TFunction, | ||
| TFunctionReturn, | ||
| InterpolationMap, | ||
| } from 'i18next'; | ||
| import * as React from 'react'; | ||
|
|
||
|
|
@@ -18,6 +20,62 @@ type _EnableSelector = TypeOptions['enableSelector']; | |
| type TransChild = React.ReactNode | Record<string, unknown>; | ||
| type $NoInfer<T> = [T][T extends T ? 0 : never]; | ||
|
|
||
| /** | ||
| * Extracts interpolation variable names from a translation string | ||
| * Examples: | ||
| * - ExtractInterpolationKeys<"Hello {{name}}!"> = ["name"] | ||
| * - ExtractInterpolationKeys<"{{count}} items from {{sender}}"> = ["count", "sender"] | ||
| * - ExtractInterpolationKeys<"No variables"> = [] | ||
| */ | ||
| type ExtractInterpolationKeys<S extends string> = S extends '' | ||
| ? [] | ||
| : S extends `${infer _Start}{{${infer Variable}}}${infer Rest}` | ||
| ? [Variable, ...ExtractInterpolationKeys<Rest>] | ||
| : []; | ||
|
|
||
| /** | ||
| * Converts an array of keys to a union type | ||
| * Example: KeysToUnion<["name", "age"]> = "name" | "age" | ||
| */ | ||
| type KeysToUnion<T extends readonly string[]> = T[number]; | ||
|
|
||
| /** | ||
| * Helper type to check if an object has all required keys | ||
| * This creates a type that requires all keys to be present | ||
| */ | ||
| type RequireKeys<T extends string> = T extends never | ||
| ? Record<string, never> | undefined | ||
| : { [K in T]: unknown }; | ||
|
|
||
| /** | ||
| * Creates a Record type from extracted interpolation keys | ||
| * If no keys are extracted, returns an empty object type or undefined | ||
| * Otherwise, returns a required Record with the extracted keys | ||
| */ | ||
| type InterpolationRecord<S extends string> = | ||
| ExtractInterpolationKeys<S> extends infer Keys | ||
| ? Keys extends [] | ||
| ? Record<string, never> | undefined // No interpolation variables | ||
| : Keys extends readonly string[] | ||
| ? RequireKeys<KeysToUnion<Keys>> | ||
| : never | ||
| : never; | ||
|
|
||
| /** | ||
| * Distributive version of InterpolationMap that works correctly with union types | ||
| * This ensures each key is processed individually rather than creating an intersection | ||
| * | ||
| * This type extracts interpolation variables from translation strings and creates | ||
| * a type-safe Record that only accepts the correct variable names. | ||
| */ | ||
| type TransInterpolationMap<Ns extends Namespace, Key, TOpt extends TOptions> = Key extends any | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I added it as a fix for test failures,
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think the type is exported but doesn't parse {{variable}} syntax from translation strings, and can't provide intellisense.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The purpose of the PR is to make
If I'm not mistaken this feature is present for Besides I think you should also add test cases for the selector API created by @ahrjarrett. |
||
| ? TFunctionReturn<Ns, Key, TOpt> extends infer TranslationString | ||
| ? TranslationString extends string | ||
| ? InterpolationRecord<TranslationString> | ||
| : InterpolationMap<TFunctionReturn<Ns, Key, TOpt>> // Fallback to i18next's InterpolationMap | ||
| : never | ||
| : never; | ||
|
|
||
| export type TransProps< | ||
| Key extends ParseKeys<Ns, TOpt, KPrefix>, | ||
| Ns extends Namespace = _DefaultNamespace, | ||
|
|
@@ -36,7 +94,7 @@ export type TransProps< | |
| ns?: Ns; | ||
| parent?: string | React.ComponentType<any> | null; // used in React.createElement if not null | ||
| tOptions?: TOpt; | ||
| values?: {}; | ||
| values?: TransInterpolationMap<Ns, Key, TOpt>; | ||
| shouldUnescape?: boolean; | ||
| t?: TFunction<Ns, KPrefix>; | ||
| }; | ||
|
|
@@ -71,7 +129,7 @@ export interface TransSelectorProps< | |
| ns?: Ns; | ||
| parent?: string | React.ComponentType<any> | null; // used in React.createElement if not null | ||
| tOptions?: TOpt; | ||
| values?: {}; | ||
| values?: TransInterpolationMap<Ns, Key, TOpt>; | ||
| shouldUnescape?: boolean; | ||
| t?: TFunction<Ns, KPrefix>; | ||
| } | ||
|
|
||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -117,4 +117,91 @@ describe('<Trans />', () => { | |||||
| }); | ||||||
| }); | ||||||
| }); | ||||||
|
|
||||||
| describe('values prop with interpolation type hints', () => { | ||||||
| it('should accept correct values for interpolation', () => { | ||||||
| // <Trans i18nKey="title" values={{ appName: 'My App' }} /> | ||||||
| // The values prop now provides intellisense for the appName variable | ||||||
| expectTypeOf(Trans).toBeCallableWith({ | ||||||
| i18nKey: 'title', | ||||||
| values: { appName: 'My App' }, | ||||||
| }); | ||||||
| }); | ||||||
|
|
||||||
| it('should accept correct values with multiple interpolation variables', () => { | ||||||
| // <Trans i18nKey="message" values={{ count: 5, sender: 'John' }} /> | ||||||
| // The values prop now provides intellisense for count and sender variables | ||||||
| expectTypeOf(Trans).toBeCallableWith({ | ||||||
| i18nKey: 'message', | ||||||
| values: { count: 5, sender: 'John' }, | ||||||
| }); | ||||||
| }); | ||||||
|
|
||||||
| it('should provide type hints for interpolation variables', () => { | ||||||
| // The values prop type is now InterpolationMap<TFunctionReturn<...>> | ||||||
| // which extracts variables from the translation string | ||||||
| // This provides intellisense in IDEs showing: { appName: unknown } | ||||||
| expectTypeOf(Trans).toBeCallableWith({ | ||||||
| i18nKey: 'title', | ||||||
| values: { appName: 'My App' }, | ||||||
| }); | ||||||
| }); | ||||||
|
|
||||||
| it('should work with keys that have no interpolation', () => { | ||||||
| // Keys without interpolation accept any values object | ||||||
| expectTypeOf(Trans).toBeCallableWith({ | ||||||
| i18nKey: 'foo', | ||||||
| values: {}, | ||||||
| }); | ||||||
| }); | ||||||
|
|
||||||
| it('should work with t function and provide type hints', () => { | ||||||
| const { t } = useTranslation('custom'); | ||||||
|
|
||||||
| // When using t function, values prop gets same type hints | ||||||
| expectTypeOf(Trans).toBeCallableWith({ | ||||||
| t, | ||||||
| i18nKey: 'title', | ||||||
| values: { appName: 'My App' }, | ||||||
| }); | ||||||
| }); | ||||||
|
|
||||||
| it('should work with greeting key', () => { | ||||||
| // <Trans i18nKey="greeting" values={{ name: 'John' }} /> | ||||||
| expectTypeOf(Trans).toBeCallableWith({ | ||||||
| i18nKey: 'greeting', | ||||||
| values: { name: 'John' }, | ||||||
| }); | ||||||
| }); | ||||||
|
|
||||||
| it('should reject incorrect interpolation variable names', () => { | ||||||
| // Should fail: wrongName is not a valid interpolation variable for 'title' | ||||||
| expectTypeOf(Trans).toBeCallableWith({ | ||||||
| i18nKey: 'title', | ||||||
| // @ts-expect-error - wrongName is not a valid interpolation variable | ||||||
| values: { wrongName: 'My App' }, | ||||||
| }); | ||||||
| }); | ||||||
|
|
||||||
| it('should reject missing required interpolation variables', () => { | ||||||
| // Should fail: appName is required for 'title' | ||||||
| // Test the values prop type directly | ||||||
| type TitleProps = React.ComponentProps<typeof Trans<'title'>>; | ||||||
| type ValuesType = TitleProps['values']; | ||||||
|
|
||||||
| // @ts-expect-error - empty object should not be assignable when appName is required | ||||||
| const invalidValues: ValuesType = {}; | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
You need to add |
||||||
|
|
||||||
| expectTypeOf<ValuesType>().not.toMatchTypeOf<{}>(); | ||||||
| }); | ||||||
|
|
||||||
| it('should reject extra interpolation variables', () => { | ||||||
| // Should fail: extra is not a valid interpolation variable for 'title' | ||||||
| expectTypeOf(Trans).toBeCallableWith({ | ||||||
| i18nKey: 'title', | ||||||
| // @ts-expect-error - extra is not a valid interpolation variable | ||||||
| values: { appName: 'My App', extra: 'value' }, | ||||||
| }); | ||||||
| }); | ||||||
| }); | ||||||
| }); | ||||||







Uh oh!
There was an error while loading. Please reload this page.