Issue
Let's say I have a Resource interface which has some translations.
This is my "record base" for translated models.
And I have similar interfaces for "real" records, like products:
interface Translation {
locale: string
}
interface Resource<T extends Translation> {
translations: T[]
}
interface ProductTranslation {
locale: string,
title: string,
status: number
}
interface Product {
translations: ProductTranslation[]
}
I want to write a "generator" function which will return a getter for the translation attribute given, for instance, in vanilla JS :
function getTranslationValue(locale, key) {
return (resource) => {
const translation = resource.translations.find(t => t.locale === locale)
return (translation && translation[key]) || null
}
}
The only way I found to write it in TS needs to declare 2 generics types :
- the resource type (
R<T>) - the resource translation type (
T extends Translation)
Like this :
function getTranslationValue<R extends Resource<T>, T extends Translation>(
locale: R['translations'][number]['locale'],
key: keyof R['translations'][number]
) {
return (resource: R) => {
const translation = resource.translations.find(t => t.locale === locale)
const value = translation && translation[key]
return value ?? null
}
}
Then, I can use it like this :
const getTitle = generateGetTranslationValue<Product, Product['translations'][number]>('en', 'title')
getTitle(myProduct)
Is there a way to get rid of the Product['translations'][number] declaration, so the call looks like this :
const getTitle = generateGetTranslationValue<Product>('en', 'title')
getTitle(myProduct)
Solution
Your function really isn't generic in two type parameters, at least conceptually. You have one type parameter, R, that you'd like to manually specify with a type argument when you call the function, like getTranslationValue<Product>(⋯). What you've called T seems to be identical to R['translations'][number], so you can just use that directly.
Of course, without a T you can't constrain R to Resource<T>. But luckily since Resource<T> is covariant in T (see Difference between Variance, Covariance, Contravariance, Bivariance and Invariance in TypeScript) you can just constrain it to Resource<Translation> instead. That gives you
function getTranslationValue<R extends Resource<Translation>>(
locale: R['translations'][number]['locale'],
key: keyof R['translations'][number]
) {
return (resource: R) => {
const translation = resource.translations.find(t => t.locale === locale)
// const translation: Translation | undefined, oops,
const value = translation && translation[key] // error
// ------------------------> ~~~~~~~~~~~~~~~~
// Type 'keyof R["translations"][number]' cannot be used to index type 'Translation'.
return value ?? null
}
}
which unfortunately doesn't directly work. TypeScript decides that resource.translations can just be widened to Translation[], which is accurate but not generic enough for your needs. You really want it to be something like R['translations'][number][] so that find() returns a R['translations'][number] | undefined. You can explcitly annotate the type yourself, and it works:
function getTranslationValue<R extends Resource<Translation>>(
locale: R['translations'][number]['locale'],
key: keyof R['translations'][number]
) {
return (resource: R) => {
const translations: R['translations'][number][] = resource.translations;
const translation = translations.find(t => t.locale === locale)
const value = translation && translation[key]
return value ?? null
}
}
const getTitle = getTranslationValue<Product>('en', 'title');
// const getTitle: (resource: Product) => NonNullable<string | number> | null
Or you could rewrite things so that resource is seen as type Resource<R['translations'][number]> as well as R, which looks like this:
function getTranslationValue<R extends Resource<Translation>>(
locale: R['translations'][number]['locale'],
key: keyof R['translations'][number]
) {
return (resource: Extract<R, Resource<R['translations'][number]>>) => {
const translation = resource.translations.find(t => t.locale === locale)
const value = translation && translation[key]
return value ?? null
}
}
const getTitle = getTranslationValue<Product>('en', 'title')
// const getTitle: (resource: Product) => NonNullable<string | number> | null
where Extract<R, Resource<R['translations'][number]> uses the Extract utility type to "filter" R by Resource<R['translations'][number]>. It will look just like R from the caller's side (so Product and not Resource<ProductTranslation>), but inside the function the compiler sees it as assignable to both types.
Answered By - jcalz
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.