Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix context type checking #2182

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
72 changes: 62 additions & 10 deletions test/typescript/custom-types/t.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,11 +156,27 @@ describe('t', () => {

it('should accept a default context key as a valid `t` function key', () => {
expectTypeOf(t('beverage')).toMatchTypeOf('cold water');

expectTypeOf(t('beverage', { context: undefined })).toMatchTypeOf('cold water');
});

it('should throw error when no `context` is provided using and the context key has no default value ', () => {
// @ts-expect-error dessert has no default value, it needs a context
expectTypeOf(t('dessert')).toMatchTypeOf('error');

// @ts-expect-error dessert has no default value, it needs a context
expectTypeOf(t('dessert', { context: undefined })).toMatchTypeOf('error');

expectTypeOf(
// @ts-expect-error dessert has no default value, it needs a context
t('dessert', { context: false as false | '' | 0 | null }),
).toMatchTypeOf<unknown>();

// TODO: edge case which is not correctly detected currently
// expectTypeOf(
// // @ts-expect-error no default context so it must give a type error
// t('dessert', { context: undefined as 'cake' | undefined }),
// ).toMatchTypeOf<never>();
});

it('should work with enum as a context value', () => {
Expand All @@ -174,18 +190,54 @@ describe('t', () => {
expectTypeOf(t('dessert', { context: ctx })).toMatchTypeOf<string>();
});

it('should trow error with string union with missing context value', () => {
it('should throw error with string union with missing context value', () => {
enum DessertMissingValue {
COOKIE = 'cookie',
CAKE = 'cake',
MUFFIN = 'muffin',
ANOTHER = 'another',
}

const ctxMissingValue = DessertMissingValue.ANOTHER;
const getRandomDessert = (): DessertMissingValue =>
Math.random() < 0.5 ? DessertMissingValue.CAKE : DessertMissingValue.ANOTHER;

const ctxRandomValue: DessertMissingValue = getRandomDessert();

// @ts-expect-error Dessert.ANOTHER is not mapped so it must give a type error
expectTypeOf(t('dessert', { context: ctxRandomValue })).toMatchTypeOf<string>();

// @ts-expect-error Dessert.ANOTHER is not mapped so it must give a type error
expectTypeOf(t('dessert', { context: ctxMissingValue })).toMatchTypeOf<string>();
expectTypeOf(t('dessert', { context: DessertMissingValue.ANOTHER })).toMatchTypeOf<string>();

expectTypeOf(
// @ts-expect-error 'another' is not mapped so it must give a type error
t('dessert', { context: 'cake' as 'cake' | 'another' }),
).toEqualTypeOf<'a nice cake'>();
});

it('should not throw error with string union with falsy context value if it has a default context', () => {
enum BeverageValue {
BEER = 'beer',
WATER = 'water',
}

const getRandomBeverage = (): BeverageValue | undefined =>
Math.random() < 0.5 ? BeverageValue.BEER : undefined;

const ctxRandomValue = getRandomBeverage();

expectTypeOf(
t('beverage', { context: ctxRandomValue }),
).toMatchTypeOf<'a classic beverage'>();

expectTypeOf(
t('beverage', { context: 'beer' as 'beer' | 'water' | undefined }),
).toEqualTypeOf<'a classic beverage'>();

expectTypeOf(t('beverage', { context: undefined })).toEqualTypeOf<'a classic beverage'>();

expectTypeOf(
t('beverage', { context: false as false | '' | 0 | null }),
).toEqualTypeOf<'a classic beverage'>();
});

it('should work with string union as a context value', () => {
Expand All @@ -195,12 +247,12 @@ describe('t', () => {
});

// @see https://github.com/i18next/i18next/issues/2172
// it('should trow error with string union with missing context value', () => {
// expectTypeOf(
// // @ts-expect-error
// t('dessert', { context: 'muffin' as 'muffin' | 'cake' | 'pippo' }),
// ).toMatchTypeOf<string>();
// });
it('should throw error with string union with missing context value', () => {
expectTypeOf(
// @ts-expect-error
t('dessert', { context: 'muffin' as 'muffin' | 'cake' | 'pippo' }),
).toMatchTypeOf<string>();
});
});

describe('context + explicit namespace', () => {
Expand Down
24 changes: 23 additions & 1 deletion typescript/t.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,23 @@ export type KeyWithContext<Key, TOpt extends TOptions> = TOpt['context'] extends
? `${Key & string}${_ContextSeparator}${TOpt['context']}`
: Key;

type ContextOfKey<
Ns extends Namespace,
Key,
TOpt extends TOptions,
Keys extends $Dictionary = KeysByTOptions<TOpt>,
ActualNS extends Namespace = NsByTOptions<Ns, TOpt>,
ActualKeys = Keys[$FirstNamespace<ActualNS>],
> = $IsResourcesDefined extends true
? Key extends `${infer Nsp}${_NsSeparator}${infer RestKey}`
? Nsp extends Namespace
? ContextOfKey<Nsp, RestKey, TOpt>
: never
: ActualKeys extends `${Key extends string ? Key : never}${_ContextSeparator}${infer Context}`
? Context
: never
: string;

export type TFunctionReturn<
Ns extends Namespace,
Key,
Expand Down Expand Up @@ -264,7 +281,12 @@ export interface TFunction<Ns extends Namespace = DefaultNamespace, KPrefix = un
const ActualOptions extends TOpt & InterpolationMap<Ret> = TOpt & InterpolationMap<Ret>,
>(
...args:
| [key: Key | Key[], options?: ActualOptions]
| [
key: Key | Key[],
options?: Omit<ActualOptions, 'context'> & {
context?: ContextOfKey<Ns, Key, TOpt> | false | '' | 0 | null;
},
]
| [key: string | string[], options: TOpt & $Dictionary & { defaultValue: string }]
| [key: string | string[], defaultValue: string, options?: TOpt & $Dictionary]
): TFunctionReturnOptionalDetails<Ret, TOpt>;
Expand Down