I'm using the the library https://github.com/ivanhofer/typesafe-i18n
This library generates strongly typed translation information and functions, like below. (the following examples are simplified)
export type MyTranslations = {
Hello: (arg: { field: unknown}) => string
Bye: (arg: { field: unknown, date: unknown}) => string
Foo: (arg: { field: unknown}) => unknown
Bar: (arg: { max: unknown}) => unknown,
test: string // this is just here to show that not every property of MyTranslations needs to be a function
}
const translations: MyTranslations = {
Hello: (arg: { field: unknown}) => 'hello',
Bye: (arg: { field: unknown, date: unknown}) => 'bye',
Foo: (arg: { field: unknown}) => 'foo',
Bar: (arg: { max: unknown}) => 'bar',
test: '' // this is just here to show that not every property of MyTranslations needs to be a function
}
Now in my code I have a function which should translate messages dynamically, it does not know exactly what I has to translate.
Through TS typing information it knows what I might translate (with keyof
).
Here is the code so far.
I spend already quite some time and I'm not sure if it is even possible or sensible, but I just want to know :)
// preparation
interface MyParams {
[index: string]: boolean | number | string | undefined
field?: keyof MyTranslations
}
interface Result {
transKey: keyof MyTranslations,
params?: MyParams
}
const results: Result[] = [
{
transKey: 'Hello',
params: {
field: 'Bye'
}
},
{
transKey: 'Bar',
params: {
max: 'test'
}
}
]
type PickByType<T, V> = {
[P in keyof T as T[P] extends V | undefined ? P : never]: T[P]
}
the translation function
function translate(results: Result[]) {
results.forEach((result: Result) => {
type A = PickByType<MyTranslations, Function>
type C = keyof A
if(result.params) {
type T = typeof result.params
type Req = Required<T>
const req = result.params as Req
const func = translations[result.transKey]
type F = typeof func
const f = translations as A
f[result.transKey as C](req)
}
})
}
translate(results)
The problem is here f[result.transKey as C](req)
Error
Argument of type 'Required<MyParams>' is not assignable to parameter of type '{ field: unknown; } & { field: unknown; date: unknown; } & { field: unknown; } & { max: unknown; }'.
Property 'date' is missing in type 'Required<MyParams>' but required in type '{ field: unknown; date: unknown; }'
Which makes sense. Typescript expects an intersection type.
So I thought maybe I can create this type somehow (holding all the required parameters field, max and date
and then, according to this type information create a new object of this new type holding this information, like so in pseudo code
type D = getAllParametersFromTypeAsIntersectionType() // <- this is easy
const newParams = createNewParamsAsTypeD(result.params)
Any ideas?
You don't really want to treat result.params
as an intersection type. First of all, it's not one: it would need to have every single property (e.g., {field: ⋯, date: ⋯, max: ⋯}
) but in practice you're only passing the properties needed by translations[result.transKey]
for the particular result.transKey
. The reason TypeScript expects and intersection is because it has no idea about the intended higher order relationship between result.transKey
and result.params
. Your Result
type doesn't actually encode any such relationship (you could write { transKey: 'Hello', params: { max: 'Bye' } }
and it would be accepted, even though that's not the right params
type for Hello
). And even if you did encode it as a union of acceptable types for each transKey
, it wouldn't automatically work inside the forEach()
callback because TypeScript can't deal well with "correlated unions".
This lack of direct support for correlated unions is covered in microsoft/TypeScript#30581. The recommended approach is to refactor to use generics in a particular way, as described in microsoft/TypeScript#47109.
The idea is to write a "base" object type that represents the underlying key-value relationship you care about, and then all your operations should use that type, generic indexes into that type, and generic indexes into mapped types over that type.
Your base object type would be
interface TransArg {
Hello: { field: unknown; };
Bye: { field: unknown; date: unknown; };
Foo: { field: unknown; };
Bar: { max: unknown; };
}
You can actually compute this from MyTranslations
as follows:
type TransKey = {
[K in keyof MyTranslations]: MyTranslations[K] extends (arg: any) => any ? K : never
}[keyof MyTranslations]
// type TransKey = "Hello" | "Bye" | "Foo" | "Bar"
type TransArg = { [K in TransKey]: Parameters<MyTranslations[K]>[0] }
The TransKey
thing is essentially keyof PickByType<MyTranslations, Function>
in your version. Note that this is all just to avoid the test
key, which is sort of a distraction here from your main issue, but it's easily overcome so that's fine.
And then TransArg
maps over TransKey
to grab just the parameter type for the method. Now we need to rewrite the type of translations
in terms of TransArg
, as follows:
const _translations: { [K in TransKey]: (arg: TransArg[K]) => void } =
translations;
This isn't really doing anything other than verifying that translations
is of that mapped type, but now we can use _translations
in place of translations
and the compiler will be better able to follow what it does for arbitrary key K
, since it's explicitly encoded in the type (as opposed to MyTranslations
which only has such information implicitly).
We can now write Result
more accurately as a distributive object type (as coined in ms/TS#47109):
type Result<K extends TransKey = TransKey> =
{ [P in K]: { transKey: P, params?: TransArg[P] } }[K]
So Result<K>
for a particular K
is just the type of Result
suitable for that transKey
. And Result<TransKey>
is the full union of Result<K>
for each K
in TransKey
. And the default type argument TransKey
means that writing just Result
gives us that full union. Now you can write
const results: Result[] = [
{
transKey: 'Hello',
params: {
field: 'Bye'
}
},
{
transKey: 'Bar',
params: {
max: 'test'
}
}
]
And if you tried to mix up the params
there (e.g., use {max: 'test'}
for 'Hello'
) you'd get an error.
We're almost done. Now we can call results.forEach()
with a generic callback function:
function translate(results: Result[]) {
results.forEach(<K extends TransKey>(result: Result<K>) => {
if (result.params) _translations[result.transKey](result.params);
})
}
Inside the callback, _translations[result.transKey]
is of the single generic type (arg: TransArg[K]) => void
, while the type of result.params
is TransArg[K]
(well, it was TransArg[K] | undefined
but we've eliminated undefined
by the check if (result.params)
). And so you have a single function type that accepts an argument corresponding exactly to the argument we're passing it. So that compiles with no problem.