Skip to content

feat: adds new "selector" API#2322

Merged
adrai merged 57 commits into
i18next:masterfrom
ahrjarrett:selector
Aug 20, 2025
Merged

feat: adds new "selector" API#2322
adrai merged 57 commits into
i18next:masterfrom
ahrjarrett:selector

Conversation

@ahrjarrett

@ahrjarrett ahrjarrett commented Jun 15, 2025

Copy link
Copy Markdown
Member

Closes #1883
Closes #1972
Closes #2042
Closes #2198
Closes #2204
Closes #2276
Closes #2317
Closes #2320
Closes #2321
Closes i18next/react-i18next#1849
Merge before i18next/react-i18next#1852
Merge before i18next/i18next-gitbook#235
Merge before i18next/react-i18next-gitbook#151
Supersedes #2319

Description

This PR adds a new "selector" API for querying translations.

Feedback on any part of this PR is welcome! Let's make i18next DX even better together 👍

Note

  1. to use this feature, you'll need to add enableSelector: true or enableSelector: 'optimize' to you config
  2. now when you make a query for a translation, you'll pass a selector function as the first argument to t.

Using it looks like this:

i18next-selector-api

Here's an example that shows pluralization being enforced, and the translations being "pruned" when context is provided:

i18next-selector-context-example

Benefits

  1. Translations are more discoverable
  • This isn't as much of an issue with small translation sets, but users with larger translation sets can now leverage the native autocompletion API to find the translation they need
  1. JSDoc annotations are preserved
  • Users now have a way to see any comments that have been made to translations from the call-site, and can even link to other translations
  1. "Go to definition" works like you'd expect
  • This works even when users change namespaces. Special care was taken when building the types to ensure that all mappings are "homomorphic" (which is just a fancy way of saying that they preserve the link to the original key).

  • With this feature, users don't need to "track down" translations any more. If they want to see where a translation is defined, they can simply jump to it in their editor. This makes troubleshooting issues easier, and helps users stay in flow.

  1. With enableSelector set to "optimize", users see a ~1,700x improvement in type-level performance
  • That means i18next is now capable of handling arbitrarily large translation sets

  • Before/after example using a generated translation set of 30,000 keys, 10 levels deep:

i18next-selector-extra-large

  • Benchmarks:

    • Before: Unable to run benchmarks: running tsc crashes due to the JavaScript heap being out of memory, IDE quits after ~8 seconds with Type instantiation is excessively deep, possibly infinite error

    • After (459 instantiations for ~30,000 keys)

  1. With enableSelector set to true, users see a ~300-400% improvement in type-level performance
  1. No more "doom scrolling"
  • Before, users with large translation sets (200+ keys) would find themselves needing to scroll through a large list of translations to find the translation they're looking for:

i18next-eager

  • After, users are able to explore their translation dictionaries one level at a time

Implementation

  • Uses the native Proxy.revocable API under the hood.

  • The proxy keeps track of property accesses, then converts them into a format that i18next already understands.

  • The proxy is revoked automatically after the selector is called so that the proxy can be garbage collected.

Migration

To assist with the migration story, I've implemented a codemod that will take care of the migration for users.

For users who'd prefer to handle the migration themselves, here are the changes you'll need to apply when turning on enableSelector:

  1. Namespace changes now happen explicitly, via the options object

Tip

We recommend you use the codemod to automate this step for you

  • Example:

    // BEFORE:
    t('ns:a.b.c')
    
    // AFTER:
    t($ => $.a.b.c, { ns: 'ns' })
  1. Default value must be explicitly passed via options

Tip

We recommend you use the codemod to automate this step for you

Note

A small type-level change has been made that could impact TypeScript users: when a user provides a default value, the return type of the t function is now a union of the selected type, and the default value's type

  • Example:
const resources = { a: { b: { c: 'some value' } } } as const

// BEFORE:
const before = t('a.b.c', 'some default value')
//     ^? const before: 'some value'

// AFTER:
const after = t($ => $.a.b.c, { defaultValue: 'some default value' })
//     ^? const after: 'some value' | 'some default value'
  1. Instead of passing an array of keys, users must provide fallback values explicitly

    • This can be accomplished by calling t again in defaultValue property of the options object

Tip

We recommend you use the codemod to automate this step for you

  • Example:

    const resources = {
      error: {
        unspecific: "Something went wrong",
        404: "The page was not found"
      } 
    } as const
    
    const error = '404'
    
    // BEFORE:
    const ex_01 = i18next.t([`error.${error}`, 'error.unspecific'])
    console.log(ex_01) // -> "The page was not found"
    
    // AFTER:
    const ex_02 = i18next.t($ => $.error[error], { defaultValue: t($ => $.error.unspecific) })
    console.log(ex_02) // -> "The page was not found"

Todo

Closing thoughts

The proxy implementation works without a hitch. I fuzz-tested keysFromSelector to build up my own confidence, but I ultimately ripped it out for this PR to keep the cognitive overhead low. Let me know if you'd like me to add that back.

I also added ~200 type-level tests, so I'm fairly confident that there won't be any surprises there.

Checklist

  • only relevant code is changed (make a diff before you submit the PR)
  • run tests npm run test
  • tests are included
  • commit message and code follows the Developer's Certification of Origin

Checklist (for documentation change)

  • only relevant documentation part is changed (make a diff before you submit the PR)
  • motivation/reason is provided
  • commit message and code follows the Developer's Certification of Origin

refactor: simplifies namespace override implementation
fix: typelevel bug with returnObjects
@ahrjarrett

This comment was marked as outdated.

@adrai

adrai commented Jun 15, 2025

Copy link
Copy Markdown
Member

@adrai I just stumbled across this issue in the react-i18next project.

Is that the API you'd prefer (filtering out plural, and just using the version without the plural suffix?

by default, yes

@szimek

szimek commented Aug 19, 2025

Copy link
Copy Markdown

Thank you so much for working on this! We're current still on i18next 21.x and react-i18next 11.x, as we had OOM errors with later versions and TS (we've got about 50 namespaces with 5K keys in total), so I'm really looking forward to this.

One question about typing. In some cases we've got a code similar to this defined outside of the component (as it's completely static):

const tabs = [
  {
    label: 'labels.a',
    tooltip: 'tooltips.a',
    route: '/a',
  },
  {
    label: 'labels.b',
    tooltip: 'tooltips.b',
    route: '/b',
  }
] as const

and then use this inside a component, e.g.

const { t: tCommon } = useTranslation('common')

return <Tooltip title={tCommon(tabs[0].tooltip}>
  <Tab label={tCommon(tabs[0].label)}  />
</Tooltip>

How would that work with this new selector API?

@ahrjarrett

ahrjarrett commented Aug 19, 2025

Copy link
Copy Markdown
Member Author

@szimek good question. I ran into this in a few places when applying the codemod to the freeCodeCamp repo.

I think the answer will depend on your use case. This is one of the few cases that the codemod can't automate.

You have a few options:

1. See if you can remove the intermediate data structure altogether

function Component({ tab }: { tab: 'a' | 'b' }) {
  const { t } = useTranslation('common')

  return <Tooltip title={t($ => $.tooltips[tab]}>
    {/*                         𐙘_____________𐙘 */}
    <Tab label={t($ => $.labels[tab])}  />
  </Tooltip>
}

Pros: Code is more direct/readable. You also get to delete code, which decreases entropy overall.

Cons: The approach requires you to restructure things a bit, which takes time and makes the migration a bit riskier.

2. Write a helper that turns the path into an accessor

const pathToAccessor = (path: string) => ($: unknown) => path
  .split('.') // ← Note: if you use a non-standard key separator, split on that instead
  .reduce((acc, cur) => (acc as { [x: string]: unknown })[cur], $) as string

const tabs = [
  { label: 'labels.a', tooltip: 'tooltips.a' },
  { label: 'labels.b', tooltip: 'tooltips.b' }
] as const

function Component() {
  const { t } = useTranslation('common')

  return <Tooltip title={t(pathToAccessor(tabs[0].tooltip)}>
    {/*                    𐙘_____________________________𐙘 */}
    <Tab label={t(pathToAccessor(tabs[0].label))}  />
  </Tooltip>
}

Pros: Fast, low-risk.

Cons: It adds a layer of abstraction, and might confuse other devs if they don't understand the purpose.

3. Cast the translation key as never

Under the hood, the new API uses a proxy to convert property accesses into a path that i18next already understands.

That means you can technically continue using the path as before, you just have to tell TypeScript to trust you.

const tabs = [
  { label: 'labels.a', tooltip: 'tooltips.a' },
  { label: 'labels.b', tooltip: 'tooltips.b' }
] as const

function Component() {
  const { t } = useTranslation('common')

  return <Tooltip title={t(tabs[0].tooltip as never}>
    {/*                                    𐙘______𐙘 */}
    <Tab label={t(tabs[0].label as never)}  />
  </Tooltip>
}

Pros: Fastest option. Zero risk, since you're only changing the types.

Cons: It's not type-safe, and doesn't feel great.

Which to choose?

If you only use this pattern in a few places, I recommend Option 1 if you can swing it, that way you can finish the migration in one go.

If you use this pattern a lot in your codebase, I recommend you tackle the migration in 2 phases:

  1. Use the codemod, and apply Option 2 or Option 3 as needed
  2. As a separate task, go back and fix the just the places where you used Option 2 or Option 3

@ahrjarrett

ahrjarrett commented Aug 19, 2025

Copy link
Copy Markdown
Member Author

@szimek one other approach you could use, that might make more sense for your use case specifically:

const tabs = [
  {
    label: 'a',
    //     𐙘_𐙘 used to be: 'labels.a'
    tooltip: 'a',
    //       𐙘_𐙘 used to be: 'tooltips.a'
    route: '/a',
  },
  {
    label: 'b',
    tooltip: 'b',
    route: '/b',
  }
] as const

function Component() {
  const { t } = useTranslation('common')

  return <Tooltip title={t($ => $.tooltips[tabs[0].tooltip]}>
    {/*                         𐙘_________________________𐙘 */}
    <Tab label={t($ => $.labels[tabs[0].label])}  />
  </Tooltip>
}

@szimek

szimek commented Aug 19, 2025

Copy link
Copy Markdown

@ahrjarrett Thank you so much!

@marcalexiei

Copy link
Copy Markdown
Contributor

The migration in my apps is completed.

Summary

Type check times (enableSelector: true)

System info

 System:
    OS: macOS 15.6
    CPU: (12) arm64 Apple M2 Max
    Memory: 17.87 GB / 64.00 GB
    Shell: 5.9 - /bin/zsh

Note that timings include an entire app typecheck (not only i18next)

Typescript: 5.8.2

Namespaces Before After
2 (200 keys each) ~2.0s ~1.7s
10 (from 100 to 600 keys each) ~3.7s ~3.2s
26 (from 100 to 600 keys each) ~19.8s ~11.4s

Found issues

Note

Opened issues are on the codemod and are not blocking to a release

Codemod

Nice to have

React i18next

I18next

@adrai

adrai commented Aug 20, 2025

Copy link
Copy Markdown
Member

Nice 👍
@ahrjarrett @marcalexiei so if for you it's ready to release, I will merge and plan a new major minor bump.
Let me know...

@marcalexiei

Copy link
Copy Markdown
Contributor

From your comment,
I initially understood this should be a minor release:

If this is really just additive, and there is no "backwards compatibility issue", I would suggest we do a minor release... (mentioning a future deprecation)... then on the next major, officially deprecate it, and the next next major, remove the deprecated code (if there is not a major blocker or similar).

If the idea is to go straight to a major release,
I’d suggest at least adding a deprecation notice on the affected exports.

Personally, I’d lean toward a minor release first, so people can try out the feature and share feedback.
Right now, aside from me and @ahrjarrett, I’m not sure how many others have tested it,
since it requires pointing to a specific commit and building manually
(because the dist isn’t versioned across both repos: this one and react-i18next).

@adrai

adrai commented Aug 20, 2025

Copy link
Copy Markdown
Member

sorry... intended minor version (probably: v25.4.0)

@ahrjarrett ahrjarrett requested a review from marcalexiei August 20, 2025 12:43
@ahrjarrett

ahrjarrett commented Aug 20, 2025

Copy link
Copy Markdown
Member Author

@ahrjarrett

Copy link
Copy Markdown
Member Author

I'm ready to 🚢 it if @marcalexiei is. I re-requested his review so he approve if/when he's ready

@adrai adrai merged commit 6c26133 into i18next:master Aug 20, 2025
9 checks passed
@adrai

adrai commented Aug 20, 2025

Copy link
Copy Markdown
Member

v25.4.0 has just been released

@adrai

adrai commented Aug 20, 2025

Copy link
Copy Markdown
Member

I want to thank you all (but especially you two @ahrjarrett and @marcalexiei ) for the great work on this 👏

I’ve just published a blog post about it * - hopefully this helps more users discover it, give it a try, and share feedback. The more people experiment with it, the better.

* I hope all code snippets in the blog post are correct and the text has not too much errors ;-)

@ahrjarrett

Copy link
Copy Markdown
Member Author

@marcalexiei is the 🐐

@ahrjarrett

Copy link
Copy Markdown
Member Author

Also shout out to @dflourusso, who gave feedback on the original implementation of this feature over a year ago

@marcalexiei

Copy link
Copy Markdown
Contributor

Thanks, @ahrjarrett! However I’d say that title rightfully goes to you:
you not only implemented the feature but also created all the codemods that made the migration process smoother.
Cheers! 🍻

@reckter

reckter commented Aug 23, 2025

Copy link
Copy Markdown

I love this!

We have a few cases where we select "the best option" of translation that is available lile this:

<Trans
	i18nKey={chooseKey([
		`a.${it.type}-${it.semanticType}`,
		`a.${it.semanticType}`,
		`a.${it.type}`,
	])}
	t={t}
/>

with chooseKey roughly being:

const chooseKey =  (keys: Array<string>) =>
	keys.find(it =>
		i18n.exists(`${String(prefix)}.${it}`, { ns: namespace }),
	) ?? keys.join(",")

Is there a neat way of doing the same thing with the new api?

i18nKey={$ =>
	$.a[`${it.type}-${it.semanticType}`] ??
	$.a[`${it.semanticType}`] ??
	$.a[`${it.type}`]
}

feels correct, but because ${it.type}-${it.semanticType} might not be there, this has type errors everywhere :/

@ahrjarrett

Copy link
Copy Markdown
Member Author

@reckter good question. If you only do this in a few places, it might be worth keeping the code the same as it was, and casing the string as never (option #3 here).

Under the hood, $ is a Proxy that tracks sequential property access, and uses it to build up the string you used to pass to the t function. It has no real properties of its own, so checking if it contains a particular key won't work.

If you can share the shape of your translation dictionaries I'm happy to help brainstorm a more elegant solution.

@giavinh79

Copy link
Copy Markdown

Hi team, thanks for shipping this feature! Really looking forward to the type-checking performance gains once the migration is finished 🚀

I ran the codemod on a large React monorepo and found out that while it handles most cases (1000+), dynamic keys and template strings are often skipped. Additionally, with stricter type checking in place in general, there are still hundreds of files remaining that require manual effort to resolve their type errors.

It's expected that the codemod can't safely refactor everything especially because in some areas, I've noticed we've adopted some suboptimal patterns with i18n. However in practice this means we need to refactor all these cases ourselves or fall back on less type-safe solutions like as never which can be quite tedious and risky to do all at once.

Is it possible to leverage a more incremental adoption path to reduce migration risk? For example:

  • Migrating module-by-module instead of all-at-once or
  • Somehow keeping the legacy API available (in a deprecated state) alongside the new one

Thanks!

@ahrjarrett

ahrjarrett commented Sep 3, 2025

Copy link
Copy Markdown
Member Author

@giavinh79 if the project is a monorepo, you might find it easier to migrate one workspace at a time using the approach we discussed in #2344.

tldr; you can create a separate i18next.d.ts per tsconfig.json file, which might be enough to apply the strangler pattern.

If that's not enough to make the migration doable, let me know, happy to keep brainstorming

@RohovDmytro

Copy link
Copy Markdown

Wow. Just found this.

Impressive!

@gregor-mueller

Copy link
Copy Markdown

The improved DX is very welcome! 👍
Still, I am a bit hesitant to switch as the changes will most probably introduce runtime performance degradation.

Does anyone have benchmarks on how the runtime performance is actually affected?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet