Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
### 16.2.4

- try to fix "Trans component do not render anymore children as default value in test environment" [1883](https://github.com/i18next/react-i18next/issues/1883) by also respecting [1876](https://github.com/i18next/react-i18next/issues/1876)

### 16.2.3

- fix hyphened component break issue [1882](https://github.com/i18next/react-i18next/pull/1882)

### 16.2.2

- fix trans component break with less than sign [1880](https://github.com/i18next/react-i18next/pull/1880), closes [1734](https://github.com/i18next/react-i18next/issues/1734)
Expand Down
62 changes: 60 additions & 2 deletions TransWithoutContext.d.ts
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Testing this in a real app:

There is an issue with string (import from json) + selector API and context:

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

Image
Type '{ test: string; }' is not assignable to type 'undefined'.
The expected type comes from property 'values' which is declared here on type 'IntrinsicAttributes & TransSelectorProps<SelectorFn<{ prova: string; prova_other: string; job: string; job_details: { title: string; }; food: string; food_pizza: string; }, string, { context: "pizza"; }>, "ns1", undefined, "pizza", { ...; }> & HTMLProps<...>'

Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import type {
TypeOptions,
TOptions,
TFunction,
TFunctionReturn,
InterpolationMap,
} from 'i18next';
import * as React from 'react';

Expand All @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Key extends any is always true since all types extend any.
Is this constraint / type necessary?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added it as a fix for test failures,
without it, users would need to provide all interpolation variables from all possible keys

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checkout your branch and I'm unable to get any autocomplete information within test files:

Image Image

I'm also getting different result from what is stated in the comments

Image

even calling using tsx syntax:

Image

Am I missing something?

Copy link
Contributor Author

@codomposer codomposer Nov 11, 2025

Choose a reason for hiding this comment

The 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.
to enable that, I think I should create a custom type that extracts interpolation variables from translation strings or I will modify the comment, let me know your thoughts

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The purpose of the PR is to make Trans components type-safe.
Based on this I suppose that at least one of the following behaviours on values prop:

  • (This is mandatory IMHO) Right now values is typed as a top type (any or unknown) this allows any type to be provided which is something that I do not consider safe from a type perspective.
    image

  • (Nice to have) autocomplete on values keys (CustomTypeOptions['resources'] are const strings),
    as per my previous screenshot this is not happening.
    No key is suggested, if this is not feasible the comment on test is misleading and should be changed.

  • (Nice to have) if I pass an invalid values key typescript should report an error... right now any key is accepted.
    Screenshot 2025-11-11 at 13 45 24

If I'm not mistaken this feature is present for tFunction so if additional logic from the core is needed to achieve it should be exported from i18next.

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,
Expand All @@ -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>;
};
Expand Down Expand Up @@ -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>;
}
Expand Down
20 changes: 10 additions & 10 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "react-i18next",
"version": "16.2.2",
"version": "16.2.4",
"description": "Internationalization for react done right. Using the i18next i18n ecosystem.",
"main": "dist/commonjs/index.js",
"types": "./index.d.mts",
Expand Down Expand Up @@ -72,7 +72,7 @@
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 25.5.2",
"i18next": ">= 25.6.2",
"react": ">= 16.8.0",
"typescript": "^5"
},
Expand Down Expand Up @@ -127,7 +127,7 @@
"eslint-plugin-testing-library": "^6.5.0",
"happy-dom": "^14.12.3",
"husky": "^9.1.7",
"i18next": "^25.6.0",
"i18next": "^25.6.2",
"lint-staged": "^15.5.2",
"mkdirp": "^3.0.1",
"prettier": "^3.6.2",
Expand Down
7 changes: 4 additions & 3 deletions react-i18next.js
Original file line number Diff line number Diff line change
Expand Up @@ -2513,7 +2513,7 @@
while (i < str.length) {
if (str[i] === '<') {
let isValidTag = false;
const closingMatch = str.slice(i).match(/^<\/(\d+|[a-zA-Z][a-zA-Z0-9]*)>/);
const closingMatch = str.slice(i).match(/^<\/(\d+|[a-zA-Z][a-zA-Z0-9-]*)>/);
if (closingMatch) {
const tagName = closingMatch[1];
if (/^\d+$/.test(tagName) || allValidNames.includes(tagName)) {
Expand All @@ -2523,7 +2523,7 @@
}
}
if (!isValidTag) {
const openingMatch = str.slice(i).match(/^<(\d+|[a-zA-Z][a-zA-Z0-9]*)(\s+[\w-]+(?:=(?:"[^"]*"|'[^']*'|[^\s>]+))?)*\s*(\/)?>/);
const openingMatch = str.slice(i).match(/^<(\d+|[a-zA-Z][a-zA-Z0-9-]*)(\s+[\w-]+(?:=(?:"[^"]*"|'[^']*'|[^\s>]+))?)*\s*(\/)?>/);
if (openingMatch) {
const tagName = openingMatch[1];
if (/^\d+$/.test(tagName) || allValidNames.includes(tagName)) {
Expand Down Expand Up @@ -2765,7 +2765,8 @@
defaultValue: defaults || tOptions?.defaultValue,
ns: namespaces
};
const translation = key ? t(key, combinedTOpts) : defaultValue;
let translation = key ? t(key, combinedTOpts) : defaultValue;
if (translation === key && defaultValue) translation = defaultValue;
const generatedComponents = generateComponents(components, translation, i18n, i18nKey);
let indexedChildren = generatedComponents || children;
let componentsMap = null;
Expand Down
2 changes: 1 addition & 1 deletion react-i18next.min.js

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions src/TransWithoutContext.js
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ const escapeLiteralLessThan = (str, keepArray = [], knownComponentsMap = {}) =>
let isValidTag = false;

// Check for closing tag: </number> or </name>
const closingMatch = str.slice(i).match(/^<\/(\d+|[a-zA-Z][a-zA-Z0-9]*)>/);
const closingMatch = str.slice(i).match(/^<\/(\d+|[a-zA-Z][a-zA-Z0-9-]*)>/);
if (closingMatch) {
const tagName = closingMatch[1];
// Valid if it's a number or in our valid names list
Expand All @@ -163,7 +163,7 @@ const escapeLiteralLessThan = (str, keepArray = [], knownComponentsMap = {}) =>
const openingMatch = str
.slice(i)
.match(
/^<(\d+|[a-zA-Z][a-zA-Z0-9]*)(\s+[\w-]+(?:=(?:"[^"]*"|'[^']*'|[^\s>]+))?)*\s*(\/)?>/,
/^<(\d+|[a-zA-Z][a-zA-Z0-9-]*)(\s+[\w-]+(?:=(?:"[^"]*"|'[^']*'|[^\s>]+))?)*\s*(\/)?>/,
);
if (openingMatch) {
const tagName = openingMatch[1];
Expand Down Expand Up @@ -535,7 +535,8 @@ export function Trans({
defaultValue: defaults || tOptions?.defaultValue,
ns: namespaces,
};
const translation = key ? t(key, combinedTOpts) : defaultValue;
let translation = key ? t(key, combinedTOpts) : defaultValue;
if (translation === key && defaultValue) translation = defaultValue;

const generatedComponents = generateComponents(components, translation, i18n, i18nKey);
let indexedChildren = generatedComponents || children;
Expand Down
24 changes: 24 additions & 0 deletions test/trans.render.object.spec.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -392,3 +392,27 @@ describe('trans with numbered tags with attributes', () => {
`);
});
});

describe('trans with hyphenated component names', () => {
function TestComponent() {
return (
<Trans
defaults="Visit our website: <website-link>mywebsite.fr</website-link>"
components={{ 'website-link': <a href="https://mywebsite.fr">dummy</a> }}
/>
);
}
it('should render hyphenated component names correctly', () => {
const { container } = render(<TestComponent />);
expect(container.firstChild).toMatchInlineSnapshot(`
<div>
Visit our website:
<a
href="https://mywebsite.fr"
>
mywebsite.fr
</a>
</div>
`);
});
});
87 changes: 87 additions & 0 deletions test/typescript/custom-types/Trans.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
Copy link
Contributor

@marcalexiei marcalexiei Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const invalidValues: ValuesType = {};
assertType<ValuesType>({});

You need to add assertType in vitest import


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' },
});
});
});
});
5 changes: 5 additions & 0 deletions test/typescript/custom-types/i18next.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ declare module 'i18next' {

some: 'some';
some_me: 'some context';

// Test interpolation
title: 'Title: <strong>{{appName}}</strong>';
greeting: 'Hello {{name}}!';
message: 'You have {{count}} messages from {{sender}}';
};

alternate: {
Expand Down
Loading