How would I write this interface method correctly so that it can be generic. Note that I do not want to make entire interface generic and T
could be Record<string, string>
or another interface that extends Record<string, string>
. Minimal example below.
interface IFoo {
source: <T extends Record<string, string>>(table: string, calcEngine?: string, tab?: string, predicate?: (row: T) => boolean) => Array<T>;
}
const getFooRows = function <T extends Record<string, string>>(table: string, calcEngine?: string, tab?: string) {
var row1: Record<string, string> = {};
var row2: Record<string, string> = {};
return [row1, row2] as Array<T>;
}
const bar: IFoo = {
source(table, calcEngine, tab, predicate) {
return predicate
? getFooRows(table, calcEngine, tab).filter(r => predicate!(r))
: getFooRows(table, calcEngine, tab);
}
}
With this code, I'm hoping to do something like the following:
interface ICustomRow extends Record<string, string> {
customProp1: string;
customProp2: string;
}
const genericRows = bar.source("genericTable");
genericRows.forEach( r => {
// r is just Record<string, string>, no intellisene on r
console.log(`${r["dynamicProp1"]} ${r["dynamicProp2"]}`);
});
const customRows = bar.source<ICustomRow>("customTable");
customRows.forEach( r => {
// r should be a ICustomRow
// Intellisense should should 'customProp1/2' when I type 'r.'
// Should also be able to access r["dynamicProp1"] without compile error as well
console.log(`${r.customProp1} ${r.customProp2}`);
});
I understand that a caller could pass in a type for T
that doesn't correspond to the data held in the table identified by table
, but that will be documented as a requirement. This is mostly for 'internal' calling inside my entire typescript project to save me from always doing a .source() as Array<ICustomRow>
, it is just a preference to do .source<ICustomRow>()
. It reads better 'to me'.
However, with this code, I get following compile error:
Error TS2322 (TS)
Type '<T extends Record<string, string>>(table: string, calcEngine: string | undefined, tab: string | undefined, predicate: ((row: T) => boolean) | undefined) => Record<string, string>[]' is not assignable to type '<T extends Record<string, string>>(table: string, calcEngine?: string | undefined, tab?: string | undefined, predicate?: ((row: T) => boolean) | undefined) => T[]'.
Type 'Record<string, string>[]' is not assignable to type 'T[]'.
Type 'Record<string, string>' is not assignable to type 'T'.
'Record<string, string>' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'Record<string, string>'.
CodePudding user response:
The issue is that getFooRows()
is generic in a type parameter T
but none of the parameters in its call signature actually depend on that type parameter, so the compiler fails to infer that it's the same T
as the one for your source()
. (This is indicative of a type safety issue, but as you mention in the question that you're aware of it, I won't belabor the point here.)
The way to deal with this is to get a hold of the generic type parameter T
from the source()
call signature in the object literal, and explicitly specify it in the calls to getFooRows()
. Unfortunately, because you haven't annotated the method parameters and are relying on contextual typing, you don't have a name for that type in scope.
We could decide to explicitly annotate everything like this:
const bar: IFoo = {
source<T extends Record<string, string>>(
table: string, calcEngine?: string,
tab?: string, predicate?: (row: T) => boolean
) {
return predicate
? getFooRows<T>(table, calcEngine, tab).filter(r => predicate(r))
: getFooRows<T>(table, calcEngine, tab);
}
}
That compiles fine, but let's see how we can avoid this.
If you had a parameter t
of type T
, you could write typeof t
. But you don't. In fact, the only parameter you have even somewhat related to T
is predicate
, which is of type ((row: T) => boolean) | undefined
. (Note that this is an optional parameter, meaning it's easy to call source()
without any argument that depends on T
. This is, again, indicative of a type safety issue. And again, you know this, and I won't go on about it other than to make this parenthetical disclaimer.)
So, to extract T
from typeof predicate
, first we have to get rid of that union with undefined
. There's a provided NonNullable<T>
utility type which removes null
and undefined
from unions, so we can start with NonNullable<typeof predicate>
, which is then (row: T) => boolean
.
Now we have a function type whose parameter is what we want. There's a provided Parameters<T>
utility type that gets the tuple of the list of parameter types for a function. So we can use Parameters<NonNullable<typeof predicate>>
, which is [row: T]
.
Finally we have a tuple one one element, and we want that element type. We can grab that by indexing into it with the index 0
, to get T
. That gives us:
const bar: IFoo = {
source(table, calcEngine?, tab?, predicate?) {
type T = Parameters<NonNullable<typeof predicate>>[0]
return predicate
? getFooRows<T>(table, calcEngine, tab).filter(r => predicate(r))
: getFooRows<T>(table, calcEngine, tab);
}
}
This also compiles fine.
Note that we don't have to use utility types; instead we can use conditional type inference to pull the relevant type out more directly, like this:
const bar: IFoo = {
source(table, calcEngine?, tab?, predicate?) {
type T = typeof predicate extends
((row: infer T) => boolean) | undefined ? T : never;
return predicate
? getFooRows<T>(table, calcEngine, tab).filter(r => predicate(r))
: getFooRows<T>(table, calcEngine, tab);
}
}
Which also works.
So there you go, three ways to get a handle on the type T
to pass in to fix the error. Oh, and four, if you want to just acknowledge that there's no type safety guarantees here, you might as well just pass in the any
type and move on:
const bar: IFoo = {
source(table, calcEngine?, tab?, predicate?) {
return predicate
? getFooRows<any>(table, calcEngine, tab).filter(r => predicate(r))
: getFooRows<any>(table, calcEngine, tab);
}
}