Take a class hierarchy Chef extends Person extends GraphNode
, where each parent class is generic in an interface T
that extends the interface of its parent class. The uppermost (abstract) class defines a method accepting as its argument a subset of the keys of T
.
I'm seeing an error (highlighted below) when keys are a subset derived from T
. When the derived keys are exactly keyof T
, the code works as expected. As far as I can tell both should work – and it's notable that it does work in the final class Chef
, which is not generic.
Inheritance extensions are not behaving as I'd expect, have I missed something there?
type Literal = boolean | number | string;
type LiteralKeys<T> = { [K in keyof T]-?: T[K] extends Literal ? K : never }[keyof T];
interface IPerson { name: string; friend: Person; }
interface IChef extends IPerson { specialty: string; }
export abstract class GraphNode<T extends {} = {}> {
// set<K extends keyof T>(_key: K, _value: T[K]) { /* ... */ } // ✅
set<K extends LiteralKeys<T>>(_key: K, _value: T[K]) { /* ... */ } // ❌
}
export abstract class Person<T extends IPerson = IPerson> extends GraphNode<T> {
setName(name: string) {
this.set('name', name); // ❌ Argument of type 'string' is
// ... not assignable to parameter of type 'LiteralKeys<T>'.
}
}
export class Chef extends Person<IChef> {
setSpecialty(specialty: string) {
this.set('specialty', specialty); // ✅
}
}
const sam = new Chef();
sam.setName('Sam');
sam.setSpecialty('BBQ');
CodePudding user response:
The generic type LiteralKeys<T>
defined like
type LiteralKeys<T> =
{ [K in keyof T]-?: T[K] extends Literal ? K : never }[keyof T];
is a combination of mapped types, indexed access types and conditional types. When you pass in a T
that's some specific type like IPerson
, the compiler can evaluate it right away:
type EagerlyEvaluated = LiteralKeys<IPerson> // "name"
But when T
is or depends upon an unspecified type parameter, such as inside the body of setName()
, it has to defer evaluation, and as such it stays mostly unevaluated:
function deferred<T extends IPerson>() {
type Deferred = LiteralKeys<T>;
// type Deferred = { [K in keyof T]-?: T[K] extends Literal ? K : never; }[keyof T]
}
And the compiler isn't able to do the sort of higher order reasoning that would let it decide if some specific value like "name"
is compatible with such an unevaluated type for all T extends IPerson
. It treats that type as mostly opaque. Since it can't verify that any value is assignable to it, it will generate an error if you do assign any value to it:
const name: Deferred = "name" // error!
There are some GitHub issues around the compiler not using generic constraints like T extends IPerson
to help partially evaluate conditional types instead of completely deferring them. See microsoft/TypeScript#39787 and microsoft/TypeScript#42077 for example. Even if this were improved it might not be possible for the compiler to understand that "name"
should always be assignable to LiteralKeys<T>
when T extends IPerson
.
In the absence of getting the compiler to understand what you already know, you can use a type assertion to just tell the compiler that you are sure that "name"
is assignable and that it shouldn't worry:
export abstract class Person<T extends IPerson = IPerson> extends GraphNode<T> {
setName(name: string) {
(this as Person).set('name', name); // okay now
}
}
Here I'm asserting that it can treat this
as a Person<IPerson>
instead of a Person<T>
, at which point the relevant type for the _key
parameter is LiteralKeys<IPerson>
(which is just "name"
) and not LiteralKeys<T>
(which is ❓). Of course when we type-assert things we should take extra care to make sure we are not accidentally lying to the compiler, since the burden of verifying type safety has shifted from the compiler (which can't handle it in this instance) to the human developer (who potentially can handle it but are known to be somewhat unreliable especially immediately before or after meals or bedtime). In this case I think this as Person
is probably harmless, especially because all IPerson
subtypes will have to have a name
property whose value is assignable to string
and thus to Literal
, but it's good to pause and triple check.