Reputation: 7510
I am trying to add type support for the function useTranslations
in the next-intl package.
Lets assume my ./locales/en.ts
file looks like this
const dict = {
one: {
two: {
three: "3",
foo: "bar"
}
}
}
export default dict
The useTranslations
function accepts an optional argument for namespace. It returns another function which when called has a required namespace argument. By setting the optional argument, it reduces the need to apply that part of the namespace to the required argument. Here's an example:
const t1 = useTranslations() // no optional arg
const val1 = t("one.two.three") // 3
const val2 = t("one.two.foo") // bar
const t2 = useTranslations("one.two")
const val3 = t("three") // 3
const val4 = t("foo") // bar
I have managed to get the types working for creating a string representation of the object, but I am having trouble with the first argument continuing into the second.
Here is my code so far
import dict from "./locales/en"
// `T` is the dictionary, `S` is the next string part of the object property path
// If `S` does not match dict shape, return its next expected properties
type DeepKeys<T, S extends string> = T extends object
? S extends `${infer I1}.${infer I2}`
? I1 extends keyof T
? `${I1}.${DeepKeys<T[I1], I2>}`
: keyof T & string
: S extends keyof T
? `${S}`
: keyof T & string
: ""
interface UseTranslationsReturn<D> {
<S extends string>(
namespace: DeepKeys<D, S>,
values?: Record<
string,
| string
| number
| boolean
| Date
| ((children: ReactNode) => ReactNode)
| null
| undefined
>
): string
}
interface UseTranslationsProps<D = typeof dict> {
<S extends string>(namespace?: DeepKeys<D, S>): UseTranslationsReturn<D>
}
export const useTranslations: UseTranslationsProps = (namespace) => useNextTranslations(namespace)
I am able to set the main function like,
const t = useTranslations("one.two")
However I get an error for,
const val = t("three") // Argument of type '"three"' is not assignable to parameter of type "one"
How can the type be adjusted to resolve this?
Upvotes: 1
Views: 896
Reputation: 33061
First of all, useTranslations
should expect valid dot notation of path. I mean it should expect a union of "one" | "one.two" | "one.two.three" | "one.two.foo"
. You will find explanation in the comments:
const dict = {
one: {
two: {
three: "3",
foo: "bar"
}
}
} as const
type Dict = typeof dict
type KeysUnion<T, Cache extends string = ''> =
/**
* If T is no more an object, it means that this call
* is last in a recursion, hence we need to return
* our Cache (accumulator)
*/
(T extends PropertyKey
? Cache
/**
* If T is an object, iterate through each key
*/
: { [P in keyof T]:
(P extends string
/**
* If Cache is an empty string, it means that this call is first,
* because default value of Cache is empty string
*/
? (Cache extends ''
/**
* Since Cache is still empty, no need to use it now
* Just call KeysUnion recursively with first property as an argument for Cache
*/
? KeysUnion<T[P], `${P}`>
/**
* Since Cache is not empty, we need to unionize it with property
* and call KeysUnion recursively again,
* In such way every iteration we apply to Cache new property with dot
*/
: Cache | KeysUnion<T[P], `${Cache}.${P}`>)
: never)
}[keyof T])
// type Result = "one" | "one.two" | "one.two.three" | "one.two.foo"
type Result = KeysUnion<Dict>
You can find related answers:[ here, here, here, here] and in my article
Now you know that your namespace is safe. We need to make sure that our second (curried) function expects valid prefix. I mean, if you provided one.two
namespace, you are expect suffix to be either three
or foo
.
In order to do that, we need somehow to extract from "one" | "one.two" | "one.two.three" | "one.two.foo"
all keys which are contain one.two
. Further more we should get rid from obtained keys one.two
prefix and leave only tail part.
We can use this helper to extraxt the suffix:
type ExtractString<
T extends string,
U extends string,
Result extends string = ''
> =
// infer first char
T extends `${infer Head}${infer Tail}`
/**
* check if concatenated Result and infered char (Head)
* equal to second argument
*/
? `${Result}${Head}` extends U
/**
* if yes - return Tail and close recursion
*/
? Tail
/**
* otherwise, call recursion with Tail (without first char)
* and updated Result
*/
: ExtractString<Tail, U, `${Result}${Head}`>
: Result
/**
* 1) o === one.two ? ExtractString<ne.two.three, 'one.two','o'>
* 2) on === one.two ? ExtractString<e.two.three, 'one.two','on'>
* 2) one === one.two ? ExtractString<.two.three, 'one.two','one'>
* .....
*/
type Test = ExtractString<'one.two.three', 'one.two'>
Ok, we have valid suffix, but we need to get rid of leading dot .three
:
type RemoveDot<T extends string> = T extends `.${infer Tail}` ? Tail : T;
// three
type Test = RemoveDot<'.three'>
Please don't forget that we need to decide which keys we need to use with ExtractString
. Because we need to extract prefix only from provided namespace and not from all keys.
type ValidPrefix<
T extends string,
U extends string
> = T extends `${U}${string}` ? Exclude<T, U> : never
// "one.two.three" | "one.two.foo"
type Test = ValidPrefix<'one.two.three' | 'one' | 'one.two.foo', 'one.two'>
We almost have our types. One thing remain. We need to infer our return type.
type Values<T> = T[keyof T]
type Elem = string;
type Acc = Record<string, any>
type Predicate<Accumulator extends Acc, El extends Elem> =
El extends keyof Accumulator ? Accumulator[El] : Accumulator
type Reducer<
Keys extends Elem,
Accumulator extends Acc = {}
> =
Keys extends `${infer Prop}.${infer Rest}`
? Reducer<Rest, Predicate<Accumulator, Prop>>
: Keys extends `${infer Last}`
? Predicate<Accumulator, Last>
: never
// 42
type Test = Reducer<'one.two', {
one: {
two: 42
}
}>
Here you have an explanation of reducer with pure js example. You can find explanation also in my article
Since we have all our required utils, we can type our function:
const dict = {
one: {
two: {
three: "3",
foo: "bar"
}
},
bar: {
baz: 2
}
} as const
type Dict = typeof dict
type KeysUnion<T, Cache extends string = ''> =
T extends PropertyKey ? Cache : {
[P in keyof T]:
P extends string
? Cache extends ''
? KeysUnion<T[P], `${P}`>
: Cache | KeysUnion<T[P], `${Cache}.${P}`>
: never
}[keyof T]
type RemoveDot<T extends string> = T extends `.${infer Tail}` ? Tail : T;
type ExtractString<T extends string, U extends string, Result extends string = ''> =
T extends `${infer Head}${infer Tail}` ? `${Result}${Head}` extends U ? Tail : ExtractString<Tail, U, `${Result}${Head}`> : Result
type ValidPrefix<T extends string, U extends string> = T extends `${U}${string}` ? Exclude<T, U> : never
type ConcatNamespaceWithPrefix<N extends string, P extends string> = `${N}.${P}`
type Values<T> = T[keyof T]
type Elem = string;
type Acc = Record<string, any>
// (acc, elem) => hasProperty(acc, elem) ? acc[elem] : acc
type Predicate<Accumulator extends Acc, El extends Elem> =
El extends keyof Accumulator ? Accumulator[El] : Accumulator
type Reducer<
Keys extends Elem,
Accumulator extends Acc = {}
> =
Keys extends `${infer Prop}.${infer Rest}`
? Reducer<Rest, Predicate<Accumulator, Prop>>
: Keys extends `${infer Last}`
? Predicate<Accumulator, Last>
: never
type UseTranslationsProps<D = typeof dict> =
(() => <Prefix extends KeysUnion<D>>(prefix: Prefix) => Reducer<Prefix, D>)
& (
<
ValidKeys extends KeysUnion<D>,
Namespace extends ValidKeys
>(namespace?: Namespace) =>
<Prefix extends RemoveDot<ExtractString<ValidPrefix<KeysUnion<Dict>, Namespace>, Namespace>>>(
prefix: Prefix
) => Reducer<ConcatNamespaceWithPrefix<Namespace, Prefix>, D>
)
declare const useTranslations: UseTranslationsProps;
{
const t = useTranslations() // ok
const ok = t('one') // {two: ....}
}
{
const t = useTranslations('one.two') // ok
const ok = t('three') // 3
}
{
const t = useTranslations() // ok
const ok = t('three') // expected error
}
As you might have noticed, I have used intersection of two functions in UseTranslationsProps
it produces an overloading for calling this function without argument.
Upvotes: 3