next-translate icon indicating copy to clipboard operation
next-translate copied to clipboard

Is there a way to have the translationKey strongly typed?

Open opolo opened this issue 3 years ago β€’ 32 comments

Hi there!

Thanks for a great library :)

Question regarding Typescript: We have a Typescript solution using this library. However, we have not been able to find a way to make the translation-key provided to t() be strongly typed. The type of t seems to be t(string) no matter what we do.

Is it possible to make the input to the t() function strongly typed based on our json files with the localizations? It would help us a lot in avoiding typos.

Thanks! :)

NB. We tried downloading the complex typescript example, but that also seemed to allow any string as input to the t-function.

opolo avatar Nov 05 '21 15:11 opolo

I solved it using the following custom hook

// util-types.ts

type Join<S1, S2> = S1 extends string
  ? S2 extends string
    ? `${S1}.${S2}`
    : never
  : never;

export type Paths<T> = {
  [K in keyof T]: T[K] extends Record<string, unknown>
    ? Join<K, Paths<T[K]>>
    : K;
}[keyof T];
// useTypeSafeTranslation.ts

import useTranslation from "next-translate/useTranslation";
import { TranslationQuery } from "next-translate";
import { Paths } from "../types/util-types";

import common from "../../locales/es-es/common.json";
import home from "../../locales/es-es/home.json";
import catalog from "../../locales/es-es/catalog.json";
import auth from "../../locales/es-es/auth.json";

export type TranslationKeys = {
  common: Paths<typeof common>;
  home: Paths<typeof home>;
  catalog: Paths<typeof catalog>;
  auth: Paths<typeof auth>;
};

export const useTypeSafeTranslation = <T extends keyof TranslationKeys>(
  ns: T
) => {
  const { t, lang } = useTranslation(ns);

  return {
    t: (
      s: TranslationKeys[T],
      q?: TranslationQuery,
      o?: {
        returnObjects?: boolean;
        fallback?: string | string[];
        default?: string;
      }
    ) => t(s, q, o),
    lang,
  };
};

And then you just pass the namespace to the hook, and you have a type safe t function

import React from "react";
import { useTypeSafeTranslation } from "./useTypeSafeTranslation";

interface TestComponentProps {}

export const TestComponent: React.FC<TestComponentProps> = () => {
  const { t } = useTypeSafeTranslation("common");

  return <>{t("footer.legal.paymentMethods")}</>;
}
;

Screenshot 2021-11-11 at 13 31 04

Adapted from https://github.com/benawad/dogehouse

ajmnz avatar Nov 11 '21 12:11 ajmnz

Here's my simplified version FYI, that just patches t and adds a minimum amount of code:

import type { I18n, Translate } from "next-translate";
import useTranslation from "next-translate/useTranslation";

import type { TranslationsKeys } from "src/utility/i18n/available-translations";

type Tail<T> = T extends [unknown, ...infer Rest] ? Rest : never;

export interface TypeSafeTranslate<Namespace extends keyof TranslationsKeys>
  extends Omit<I18n, "t"> {
  t: (
    key: TranslationsKeys[Namespace],
    ...rest: Tail<Parameters<Translate>>
  ) => string;
}

export function useTypeSafeTranslation<
  Namespace extends keyof TranslationsKeys
>(namespace: Namespace): TypeSafeTranslate<Namespace> {
  return useTranslation(namespace);
}

And I also just use import type for all the translations to be extra sure they don't accidentally get bundled (tree shaking has always been a little finnicky for me). I then added a lint rule with ESLint to error whenever users in my codebase try to use useTranslation directly, which if they really need to, can disable with an ESLint disable comment. Working on an equivalent helper for Trans.

osdiab avatar Nov 12 '21 06:11 osdiab

Here's a helper for Trans:

import UnsafeTrans from "next-translate/Trans";
import type { TransProps as UnsafeTransProps } from "next-translate";

import type { TranslationsKeys } from "src/utility/i18n/available-translations";

export interface TransProps<Namespace extends keyof TranslationsKeys>
  extends Omit<UnsafeTransProps, "i18nKey"> {
  i18nKey: `${Namespace}:${TranslationsKeys[Namespace]}`;
}

export function Trans<Namespace extends keyof TranslationsKeys>(
  props: TransProps<Namespace>
): JSX.Element {
  return <UnsafeTrans {...props} />;
}

osdiab avatar Nov 12 '21 07:11 osdiab

next update i'll post is to change the Paths type to support this library's _ prefixed pluralization rules; the nested plurals sound harder to express in TypeScript.

osdiab avatar Nov 12 '21 08:11 osdiab

@osdiab what looks like your file?

import type { TranslationsKeys } from "src/utility/i18n/available-translations";

ChristoRibeiro avatar Feb 25 '22 14:02 ChristoRibeiro

Like this:

import type userProfile from "locales/en/user-profile.json";
import type common from "locales/en/common.json";

type Join<S1, S2> = S1 extends string
  ? S2 extends string
    ? `${S1}.${S2}`
    : never
  : never;

export type Paths<T> = {
  [K in keyof T]: T[K] extends Record<string, unknown>
    ? Join<K, Paths<T[K]>>
    : K;
}[keyof T];

export interface TranslationsKeys {
  common: Paths<typeof common>;
  "user-profile": Paths<typeof userProfile>;
}

It's a little annoying that I have to add this boilerplate to get the types of the locales - but I bet can make a script that does this kind of thing automatically.

osdiab avatar Feb 26 '22 11:02 osdiab

another way with this generator: https://www.npmjs.com/package/next-translate-localekeys. Is able to work with the basics for generating locale keys that are available and can be inserted in the useTranslation hook

elementrics avatar May 29 '22 09:05 elementrics

for those who are interested in a working example I setup my solution for this matter in this library https://github.com/knitkode/koine/blob/main/packages/next/types-i18n.ts it allows to augment the namespace Koine with type NextTranslations whose keys point to the defaultLocale translation files,

PS: I am also wrapping useT and the other methods tweaking some behaviours, so there is more than type safety in that library

kuus avatar Jun 30 '22 18:06 kuus

@saschahapp nice work πŸš€

quyctd avatar Sep 25 '22 09:09 quyctd

Is there any way to support type-safe params too?

quyctd avatar Oct 05 '22 11:10 quyctd

@quyctd my package does not currently support that. But if this would be an improvement, I would gladly add it.

elementrics avatar Oct 05 '22 13:10 elementrics

@saschahapp Yes, please consider it. Since Next.js usually come with typescript, having type-safe for both keys and params will be awesome πŸš€

quyctd avatar Oct 06 '22 03:10 quyctd

@osdiab Any news on pluralization?

mleister97 avatar Oct 11 '22 13:10 mleister97

No, haven’t really focused on that issue as of late.

Omar

On Tue, Oct 11 2022 at 10:57 PM, mleister97 @.***> wrote:

@osdiab https://github.com/osdiab Any news on pluralization?

β€” Reply to this email directly, view it on GitHub https://github.com/aralroca/next-translate/issues/721#issuecomment-1274730933, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAONU3W37AJHZBTIAHK2EDDWCVW27ANCNFSM5HOCJ7EA . You are receiving this because you were mentioned.Message ID: @.***>

osdiab avatar Oct 12 '22 00:10 osdiab

Are there any plans to integrate this feature into the library? i18next does that and it is the only feature that stops me from migrating to next-translate.

luixo avatar Mar 04 '23 12:03 luixo

Yes, we are going to priorize this. However feel free to PR πŸ‘πŸ˜Š

aralroca avatar Mar 05 '23 07:03 aralroca

@osdiab Any news on pluralization?

You could just clean up the resulting paths using this:

type RemoveSuffix<Key extends string> = Key extends `${infer Prefix}${
  | "_zero"
  | "_one"
  | "_two"
  | "_few"
  | "_many"
  | "_other"}`
  ? Prefix
  : Key;

boredland avatar May 05 '23 21:05 boredland

I solved it using the following custom hook

// util-types.ts

type Join<S1, S2> = S1 extends string
  ? S2 extends string
    ? `${S1}.${S2}`
    : never
  : never;

export type Paths<T> = {
  [K in keyof T]: T[K] extends Record<string, unknown>
    ? Join<K, Paths<T[K]>>
    : K;
}[keyof T];
// useTypeSafeTranslation.ts

import useTranslation from "next-translate/useTranslation";
import { TranslationQuery } from "next-translate";
import { Paths } from "../types/util-types";

import common from "../../locales/es-es/common.json";
import home from "../../locales/es-es/home.json";
import catalog from "../../locales/es-es/catalog.json";
import auth from "../../locales/es-es/auth.json";

export type TranslationKeys = {
  common: Paths<typeof common>;
  home: Paths<typeof home>;
  catalog: Paths<typeof catalog>;
  auth: Paths<typeof auth>;
};

export const useTypeSafeTranslation = <T extends keyof TranslationKeys>(
  ns: T
) => {
  const { t, lang } = useTranslation(ns);

  return {
    t: (
      s: TranslationKeys[T],
      q?: TranslationQuery,
      o?: {
        returnObjects?: boolean;
        fallback?: string | string[];
        default?: string;
      }
    ) => t(s, q, o),
    lang,
  };
};

And then you just pass the namespace to the hook, and you have a type safe t function

import React from "react";
import { useTypeSafeTranslation } from "./useTypeSafeTranslation";

interface TestComponentProps {}

export const TestComponent: React.FC<TestComponentProps> = () => {
  const { t } = useTypeSafeTranslation("common");

  return <>{t("footer.legal.paymentMethods")}</>;
}
;
Screenshot 2021-11-11 at 13 31 04

Adapted from https://github.com/benawad/dogehouse

This little rework could help us to get dicwords from another namespaces different to default which sets in useTypeSafeTranslation.

import useTranslation from "next-translate/useTranslation";
import type { TranslationQuery } from "next-translate";
import type ProfileEn from "@/../locales/en/profile.json";
import type CommonEn from "@/../locales/en/common.json";
import type ProfileRu from "@/../locales/ru/profile.json";
import type CommonRu from "@/../locales/ru/common.json";

type Join<S1, S2> = S1 extends string
  ? S2 extends string
    ? `${S1}.${S2}`
    : never
  : never;

export type Paths<T> = {
  [K in keyof T]: T[K] extends Record<string, unknown>
    ? Join<K, Paths<T[K]>>
    : K;
}[keyof T];

type All<T> = {
  [Ns in keyof T]: `${Extract<Ns, string>}:${Extract<T[Ns], string>}`;
}[keyof T];

export interface TranslationKeys {
  common: Paths<typeof CommonRu & typeof CommonEn>;
  profile: Paths<typeof ProfileRu & typeof ProfileEn>;
}

export const useTypeSafeTranslation = <T extends keyof TranslationKeys>(
  ns: T
) => {
  const { t, lang } = useTranslation(ns);

  return {
    t: (
      s: TranslationKeys[T] | All<Omit<TranslationKeys, T>>,
      q?: TranslationQuery,
      o?: {
        returnObjects?: boolean;
        fallback?: string | string[];
        default?: string;
      }
    ) => t(s, q, o),
    lang,
  };
};

mspaint_ZFjZPiRTpy

X7Becka avatar May 10 '23 12:05 X7Becka

Feel free to PR improving the types

aralroca avatar May 10 '23 14:05 aralroca

My current implementation works good with next.js 13 app directory.

next-translate.d.ts

import type { I18n, Translate } from "next-translate";
import type common from "~/../locales/en/common.json";
import type home from "~/../locales/en/home.json";

type Join<S1, S2> = S1 extends string
  ? S2 extends string
    ? `${S1}.${S2}`
    : never
  : never;

export type Paths<T> = {
  [K in keyof T]: T[K] extends Record<string, unknown>
    ? Join<K, Paths<T[K]>>
    : K;
}[keyof T];

export interface TranslationsKeys {
  common: Paths<typeof common>;
  home: Paths<typeof home>;
}

export interface TypeSafeTranslate<Namespace extends keyof TranslationsKeys>
  extends Omit<I18n, "t"> {
  t: (
    key: TranslationsKeys[Namespace],
    ...rest: Tail<Parameters<Translate>>
  ) => string;
}

declare module "next-translate/useTranslation" {
  export default function useTranslation<
    Namespace extends keyof TranslationsKeys,
  >(namespace: Namespace): TypeSafeTranslate<Namespace>;
}

eddyhdzg-solarx avatar Jun 17 '23 23:06 eddyhdzg-solarx

I modified the last example allowing plurals:

t('example', { count: 5 });  // example_other

exact matches:

t('example', { count: 99 }); // example_99

and tagged template string:

t`example` // example

next-translate.d.ts:

import type { I18n, Translate } from "next-translate";

type RemovePlural<Key extends string> = Key extends `${infer Prefix}${| "_zero"
  | "_one"
  | "_two"
  | "_few"
  | "_many"
  | "_other"
  | `_${infer Num}`}`
  ? Prefix
  : Key;

type Join<S1, S2> = S1 extends string
  ? S2 extends string
  ? `${S1}.${S2}`
  : never
  : never;

export type Paths<T> = RemovePlural<{
  [K in keyof T]: T[K] extends Record<string, unknown>
  ? Join<K, Paths<T[K]>>
  : K;
}[keyof T]>;

export interface TranslationsKeys {
  common: Paths<typeof import("./locales/en/common.json")>;
  home: Paths<typeof import("./locales/en/home.json")>;
}

export interface TypeSafeTranslate<Namespace extends keyof TranslationsKeys>
  extends Omit<I18n, "t"> {
  t: {
    (key: TranslationsKeys[Namespace], ...rest: Tail<Parameters<Translate>>): string;
    <T extends string>(template: TemplateStringsArray): string;
  };
}

declare module "next-translate/useTranslation" {
  export default function useTranslation<
    Namespace extends keyof TranslationsKeys,
  >(namespace: Namespace): TypeSafeTranslate<Namespace>;
}

I am thinking of adding a new configuration property to auto-generate and update this file as new namespaces are added. What do you think about this? πŸ€”

aralroca avatar Jul 15 '23 09:07 aralroca

Sounds like a good idea to me :)

Omar

On Sat, Jul 15 2023 at 6:59 PM, Aral Roca Gomez @.***> wrote:

I modified the last example allowing plurals:

t('example', { count: 5 }); // example_other

exact matches:

t('example', { count: 99 }); // example_99

and tagged template string:

texample // example

next-translate.d.ts:

import type { I18n, Translate } from "next-translate";import type common from "./locales/en/common.json";import type home from "./locales/en/home.json"; type RemovePlural<Key extends string> = Key extends ${infer Prefix}${| "_zero" | "_one" | "_two" | "_few" | "_many" | "_other" | _${infer Num}} ? Prefix : Key; type Join<S1, S2> = S1 extends string ? S2 extends string ? ${S1}.${S2} : never : never; export type Paths<T> = { [K in keyof T]: T[K] extends Record<string, unknown> ? Join<K, Paths<T[K]>> : K;}[keyof T]; export interface TranslationsKeys { common: RemovePlural<Paths>; home: RemovePlural<Paths>;} type TranslateKey<T> = T extends string ? RemovePlural<Paths> : never; export interface TypeSafeTranslate<Namespace extends keyof TranslationsKeys> extends Omit<I18n, "t"> { t: { (key: TranslationsKeys[Namespace], ...rest: Tail<Parameters<Translate>>): string; <T extends string>(template: TemplateStringsArray, ...keys: TranslateKey<T>[]): string; };} declare module "next-translate/useTranslation" { export default function useTranslation< Namespace extends keyof TranslationsKeys,

(namespace: Namespace): TypeSafeTranslate<Namespace>;}

I am thinking of adding a new configuration property to auto-generate and update this file as new namespaces are added. What do you think about this? [image: πŸ€”]

β€” Reply to this email directly, view it on GitHub https://github.com/aralroca/next-translate/issues/721#issuecomment-1636725412, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAONU3RFAT6SMYQFE57QRW3XQJSYDANCNFSM5HOCJ7EA . You are receiving this because you were mentioned.Message ID: @.***>

osdiab avatar Jul 16 '23 00:07 osdiab

I'd really like to find an elegant way to do it without needing to create next-translate.d.ts, but relying on the JSONs of the namespaces that everyone puts in is not clear to me. I don't know if pulling the types from the namespaces defined in the i18n.js file would work or not. Also it depends on where people have these namespaces, if it is the default form (inside locales/lang/namespace.json) maybe it could work. Well I'll investigate a bit more and see.

aralroca avatar Jul 16 '23 03:07 aralroca

for now I did a little improvement in 2.5 to simplify the next-translate.d.ts file:

  • Release notes: https://github.com/aralroca/next-translate-plugin/releases/tag/2.5.0
  • Docs: https://github.com/aralroca/next-translate/blob/2.5.0/docs/type-safety.md

Example:

import type { Paths, I18n, Translate } from 'next-translate'

export interface TranslationsKeys {
  // Example with "common" and "home" namespaces in "en" (the default language):
  common: Paths<typeof import('./locales/en/common.json')>
  home: Paths<typeof import('./locales/en/home.json')>
  // Specify here all the namespaces you have...
}

export interface TypeSafeTranslate<Namespace extends keyof TranslationsKeys>
  extends Omit<I18n, 't'> {
  t: {
    (
      key: TranslationsKeys[Namespace],
      ...rest: Tail<Parameters<Translate>>
    ): string
    <T extends string>(template: TemplateStringsArray): string
  }
}

declare module 'next-translate/useTranslation' {
  export default function useTranslation<
    Namespace extends keyof TranslationsKeys
  >(namespace: Namespace): TypeSafeTranslate<Namespace>
}

aralroca avatar Jul 17 '23 18:07 aralroca

@aralroca Thank you for pushing this forward! Unfortunately I can't get it to work - is it really only this snippet that needs to be added? I also tried adding it to tsconfig.json in the include array.

Edit: I had to include the namespace in useTranslation() - default ns won't work out of the box. Additional question: what if returnObjects is set to true? Obviously string won't work. Any idea on how we could set proper types for objects?

sandrooco avatar Jul 27 '23 08:07 sandrooco

@sandrooco it should work in the latest next-translate version. The implementation supports these 2 scenarios:

  1. namespaces in useTranslation

  2. keys in t:

goes no further. No matter what parameters you use, withreturnObjects these behaviors should work the same.

aralroca avatar Jul 27 '23 12:07 aralroca

Thanks for adding this feature! very helpful!

However, I have tried to add it and I get two errors with this code:

for now I did a little improvement in 2.5 to simplify the next-translate.d.ts file:

  • Release notes: https://github.com/aralroca/next-translate-plugin/releases/tag/2.5.0
  • Docs: https://github.com/aralroca/next-translate/blob/2.5.0/docs/type-safety.md

Example:

import type { Paths, I18n, Translate } from 'next-translate'

export interface TranslationsKeys {
  // Example with "common" and "home" namespaces in "en" (the default language):
  common: Paths<typeof import('./locales/en/common.json')>
  home: Paths<typeof import('./locales/en/home.json')>
  // Specify here all the namespaces you have...
}

export interface TypeSafeTranslate<Namespace extends keyof TranslationsKeys>
  extends Omit<I18n, 't'> {
  t: {
    (
      key: TranslationsKeys[Namespace],
      ...rest: Tail<Parameters<Translate>>
    ): string
    <T extends string>(template: TemplateStringsArray): string
  }
}

declare module 'next-translate/useTranslation' {
  export default function useTranslation<
    Namespace extends keyof TranslationsKeys
  >(namespace: Namespace): TypeSafeTranslate<Namespace>
}
  1. That Tail is not defined. I tried to recreate it myself with something like: type Tail<T extends readonly any[]> = T extends readonly [any, ...infer TT] ? TT : []; but I'm not sure this is the exact implementation
  2. in <T extends string>(template: TemplateStringsArray): string, we have defined the generic T but never used. I imagine this is the intended code? <T extends string>(template: TemplateStringsArray): T

Thanks again for this library!

valerioleo avatar Jul 28 '23 10:07 valerioleo

@valerioleo probably depends on the TypeScript version. Feel free to PR these missing parts.

About TemplateStringsArray the only thing is ignore these cases:

t`some.key`

because for now is not possible to strong type the content of some.key in template strings.

aralroca avatar Jul 28 '23 15:07 aralroca

@aralroca Thank you for pushing this forward! Unfortunately I can't get it to work - is it really only this snippet that needs to be added? I also tried adding it to tsconfig.json in the include array.

Edit: I had to include the namespace in useTranslation() - default ns won't work out of the box. Additional question: what if returnObjects is set to true? Obviously string won't work. Any idea on how we could set proper types for objects?

Here is a functional version that defaults to "common" namespace while also allowing it to be overridable:

import type { I18n, Paths, Translate } from "next-translate";

import EN from "@/locales/en/common.json";

interface TranslationsKeys {
  common: Paths<typeof EN>;
}

interface TypeSafeTranslate<Namespace extends keyof TranslationsKeys>
  extends Omit<I18n, "t"> {
  t: {
    (
      key: TranslationsKeys[Namespace],
      ...rest: Tail<Parameters<Translate>>
    ): string;
    <T extends string>(template: TemplateStringsArray): string;
  };
}

declare module "next-translate/useTranslation" {
  export default function useTranslation<
    Namespace extends keyof TranslationsKeys,
  >(namespace: Namespace = "common"): TypeSafeTranslate<Namespace>;
}

Example

t() function:

Screenshot 2023-09-08 at 15 37 32

useTranslate() hook:

Screenshot 2023-09-08 at 15 38 01

SutuSebastian avatar Sep 08 '23 12:09 SutuSebastian

Thanks for adding this feature! very helpful!

However, I have tried to add it and I get two errors with this code:

for now I did a little improvement in 2.5 to simplify the next-translate.d.ts file:

  • Release notes: https://github.com/aralroca/next-translate-plugin/releases/tag/2.5.0
  • Docs: https://github.com/aralroca/next-translate/blob/2.5.0/docs/type-safety.md

Example:

import type { Paths, I18n, Translate } from 'next-translate'

export interface TranslationsKeys {
  // Example with "common" and "home" namespaces in "en" (the default language):
  common: Paths<typeof import('./locales/en/common.json')>
  home: Paths<typeof import('./locales/en/home.json')>
  // Specify here all the namespaces you have...
}

export interface TypeSafeTranslate<Namespace extends keyof TranslationsKeys>
  extends Omit<I18n, 't'> {
  t: {
    (
      key: TranslationsKeys[Namespace],
      ...rest: Tail<Parameters<Translate>>
    ): string
    <T extends string>(template: TemplateStringsArray): string
  }
}

declare module 'next-translate/useTranslation' {
  export default function useTranslation<
    Namespace extends keyof TranslationsKeys
  >(namespace: Namespace): TypeSafeTranslate<Namespace>
}
  1. That Tail is not defined. I tried to recreate it myself with something like: type Tail<T extends readonly any[]> = T extends readonly [any, ...infer TT] ? TT : []; but I'm not sure this is the exact implementation
  2. in <T extends string>(template: TemplateStringsArray): string, we have defined the generic T but never used. I imagine this is the intended code? <T extends string>(template: TemplateStringsArray): T

Thanks again for this library!

Outdated typescript version might be the case here.

SutuSebastian avatar Sep 08 '23 12:09 SutuSebastian