QuiiBz / next-international

Type-safe internationalization (i18n) for Next.js
https://next-international.vercel.app
MIT License
1.25k stars 59 forks source link

Needless extra parameter required to avoid TS errors #231

Closed rentalhost closed 11 months ago

rentalhost commented 11 months ago

Describe the bug When using the createI18nServer (or client) function to create internationalization providers with locales provided from an external source, the t function (e.g., t("example")) generates a TypeScript error, indicating that two arguments are expected, but only one is provided.

To Reproduce Steps to reproduce the behavior:

  1. Define locales using an external source, as described in the code snippet below.
  2. Use the t function with a single parameter, e.g., t("example").
import { createI18nServer } from "next-international/server";

// External source for locales, for example purposes, provided as an array.
const locales = ["en", "pt"];

export const { getI18n } = createI18nServer(
  Object.fromEntries(
    locales.map((locale) => [locale, () => import(`./${locale}`)]),
  ),
);

Expected behavior I expected the t function to work with a single parameter (t("example")) without generating TypeScript errors.

Screenshots image image

About (please complete the following information):

next-international version: 1.1.1 Next.js version: 13.5.4 TypeScript version: 5.2.2

Additional context

I created a CodeSandbox to illustrate the issue. Despite the TypeScript error, the translated text ("example in en") appears as expected. It seems that the usage of Object.fromEntries() in conjunction with external locales may be causing this issue.

QuiiBz commented 11 months ago

As expected, the issue comes from this Object.fromEntries that doesn't narrow the correct type information:

Screenshot 2023-10-08 at 09 09 39

You can manually cast the result to get the correct type:

import { createI18nServer } from "next-international/server";

const locales = ["en", "pt"] as const;

export const { getI18n } = createI18nServer(
  Object.fromEntries(
    locales.map((locale) => [locale, () => import(`./${locale}`)]),
  ) as Record<(typeof locales)[number], () => Promise<typeof import('./en')>>
);
rentalhost commented 11 months ago

Perfect, I'll give it a try. I almost tried to do this but thought it wouldn't work and gave up (I was having trouble understanding the return precisely, which, in the end, was exactly the same problem that TS was having because of my mistake haha). Anyway, I thought TS would be able to understand the return on its own, but it seems to be having some kind of issue with Object.fromEntries() that starts returning an any instead of the precise type (even when using as consts).

QuiiBz commented 11 months ago

Yeah unfortunately many of these internal JS methods often return types that aren't narrowed properly and are too wide, for reasons that I ignore. I've tried on your CodeSanbox and it was working fine!

gustaveWPM commented 11 months ago

Have done something inspired from your proposals here! Just to give an idea:

export const getEnumKeys = (e: object): string[] => Object.keys(e).filter((key) => isNaN(Number(key)));
export default getEnumKeys;
type JSONPrimitiveLeafs = string | number | boolean | null;
type JSONLeafs = JSONPrimitiveLeafs | JSONPrimitiveLeafs[];

export type JSONData = {
  [_: string]: JSONData | JSONData[] | JSONLeafs;
};

export type TypedLeafsJSONData<LeafsTypes extends JSONLeafs, AllowObjArrays extends 'ALLOW_OBJ_ARRAYS' | never = never> = {
  [_: string]: TypedLeafsJSONData<LeafsTypes> | (AllowObjArrays extends never ? never : TypedLeafsJSONData<LeafsTypes>[]) | LeafsTypes;
};
type LanguageFlagKey = keyof typeof ELanguagesFlag;
export type LanguageFlag = LanguageFlagKey;
import DEFAULT_LANGUAGE_OBJ from '@/i18n/locales/fr';

export enum ELanguagesFlag {
  fr,
  en
}

export type VocabBase = typeof DEFAULT_LANGUAGE_OBJ;
export const LANGUAGES: LanguageFlag[] = getEnumKeys(ELanguagesFlag) as LanguageFlag[];
export const DEFAULT_LANGUAGE: LanguageFlag = DEFAULT_LANGUAGE_OBJ._infos.lng; // * ... Could be also = LANGUAGES[0], or just a raw ELanguagesFlag key, like `"fr"`. But I'd prefer to link everything to the `DEFAULT_LANGUAGE_OBJ`, cuz Im a lazybones.
type AllowedVocabObjValuesTypes = string;
type VocabObjValue = AllowedVocabObjValuesTypes;

type NextInternationalMagic = {
  default: VocabBase;
};

export type LocalesObj = Record<LanguageFlag, () => Promise<NextInternationalMagic>>;
export type LocalesGetterConfigObjTypeConstraint = Record<LanguageFlag, () => Promise<TypedLeafsJSONData<VocabObjValue>>>;
export const LOCALES_OBJ = Object.fromEntries(
  LANGUAGES.map((language) => [language, () => import(`@/i18n/locales/${language}`)])
) as LocalesObj satisfies LocalesGetterConfigObjTypeConstraint; // * ... NOT perfectly typesafe! See this to enforce the type safety of your different locales schemas: https://github.com/QuiiBz/next-international/issues/225

export default LOCALES_OBJ;
export const { getI18n: getServerSideI18n, getScopedI18n, getStaticParams } = createI18nServer(LOCALES_OBJ);
export const { useI18n: getClientSideI18n, useScopedI18n, I18nProviderClient, useCurrentLocale } = createI18nClient(LOCALES_OBJ);