typescriptinternationalizationtypescript-genericsreact-typescriptnext-intl

Need help in typing reusable component for `next-intl


I'll do my best to explain what I need. If you're not already familiar with next-intl, It's a package that provides internationalization for Next.js apps.

The Problem:

The developer of next-intl working on a beta version of next-intl that supports the app router of next.js. I installed this beta version [email protected]. Up to this point, everything is ok.

The problem is that next-intl doesn't work in async components. and devs have to move the calling of the useTranslations hook to another sync component.

I use async components for everything almost. Instead of moving the all texts into other components. I've built a reusable component <T /> (stands for Text or Translation). and want to pass the translation key as a prop. like <T k="home.hero.title" /> (k because key is already reserved).

My implementation:

Assuming the translation file is like this:

{
  "hello-world": "Hello World",
  "home": {
    "title": "Acme",
    "hero": {
      "description": "whatever"
    }
  },
  "login": {
    "button": "Login"
  }
}

components/translation.tsx

import { useTranslations } from "next-intl";

export function T({ k }: {
  k: TYPEME_LATER;
}) {
  const translate = useTranslations();
  return translate(k);
}

To keep you on the scene, my all question is about the TYPEME_LATER.

1. First, I wrote it like this:

import { useTranslations } from "next-intl";

type TranslationKey = Parameters<ReturnType<typeof useTranslations>>[0];

export function T({ k }: {
  k: TranslationKey;
}) {}

But this way, Typescript suggests only nested keys like title, hero.description, button, and hello-world (take a quick look at the previous JSON snippet). but, by this way the next-intl won't find these keys, because it misses the scope home, login.

2. So, I tried another solution:

import { useTranslations } from "next-intl";

type useTranslationsParam = Parameters<typeof useTranslations>[0];
type TranslationKey = Parameters<ReturnType<typeof useTranslations>>[0];

export function T({
  scope,
  k,
}: {
  scope: useTranslationsParam;
  k: TranslationKey;
}) {
  const translate = useTranslations(scope);
  return translate(k);
}

like the magic, that worked as expected. Now I can do this anywhere:

export default async function Page() {
  return <>
    <T scope="home" k="title" />
  </>
}

It renders as expected. but if you go back to the TranslationKey type declaration, You can see that it accepts any key regardless of the scope. This might lead to a bug when writing a key that doesn't exist in the scope.

My question is I want to make the k suggest only the keys within the scope. or if there is another approach let me know.

Thanks in advance for your interest. Your help is appreciated.


Solution

  • Update

    [email protected] came with support async components using await getTranslations(namespace?)

    export async function Page() {
      const t = await getTranslations("home");
      
      return <div>{t("title")}</div>
    }
    


    Original Answer

    For anyone trying to build the same concept, this is my end implementation:

    import { useTranslations } from "next-intl";
    
    interface Props {
      /** Message key */
      path: Paths<IntlMessages>;
    }
    
    export function Text({ path: key }: Props) {
      const translate = useTranslations();
    
      return translate(key);
    }
    
    type Paths<Schema, Path extends string = ""> = Schema extends string
      ? Path
      : Schema extends object
      ? {
          [K in keyof Schema & string]: Paths<
            Schema[K],
            `${Path}${Path extends "" ? "" : "."}${K}`
          >;
        }[keyof Schema & string]
      : never;