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.