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>
Playground
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'>
Playground
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'>
Playground
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'>
Playground
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
}
}>
Playground
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
}
Playground
As you might have noticed, I have used intersection of two functions in UseTranslationsProps
it produces an overloading for calling this function without argument.