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.paramsas 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 bytranslations[result.transKey]for the particularresult.transKey. The reason TypeScript expects and intersection is because it has no idea about the intended higher order relationship betweenresult.transKeyandresult.params. YourResulttype 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 rightparamstype forHello). And even if you did encode it as a union of acceptable types for eachtransKey, it wouldn't automatically work inside theforEach()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
You can actually compute this from
MyTranslationsas follows:The
TransKeything is essentiallykeyof PickByType<MyTranslations, Function>in your version. Note that this is all just to avoid thetestkey, which is sort of a distraction here from your main issue, but it's easily overcome so that's fine.And then
TransArgmaps overTransKeyto grab just the parameter type for the method. Now we need to rewrite the type oftranslationsin terms ofTransArg, as follows:This isn't really doing anything other than verifying that
translationsis of that mapped type, but now we can use_translationsin place oftranslationsand the compiler will be better able to follow what it does for arbitrary keyK, since it's explicitly encoded in the type (as opposed toMyTranslationswhich only has such information implicitly).We can now write
Resultmore accurately as a distributive object type (as coined in ms/TS#47109):So
Result<K>for a particularKis just the type ofResultsuitable for thattransKey. AndResult<TransKey>is the full union ofResult<K>for eachKinTransKey. And the default type argumentTransKeymeans that writing justResultgives us that full union. Now you can writeAnd if you tried to mix up the
paramsthere (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:Inside the callback,
_translations[result.transKey]is of the single generic type(arg: TransArg[K]) => void, while the type ofresult.paramsisTransArg[K](well, it wasTransArg[K] | undefinedbut we've eliminatedundefinedby the checkif (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.Playground link to code