I'm setting up a base datastructure for a project, hoping to have an abstract GraphNode
base object from which many other objects will inherit. Each GraphNode subclass has metadata that could include both literals (string, numbers, ...) and references to other GraphNode-derived types.
Example subclasses:
interface IPerson {
name: string;
age: number;
friend: Person;
pet: Pet;
}
interface IPet {
type: 'dog' | 'cat';
name: string;
}
class Person extends GraphNode<IPerson> {}
class Pet extends GraphNode<IPet> {}
Example usage:
const spot = new Pet()
.set('type', 'dog')
.set('name', 'Spot');
const jo = new Person()
.set('name', 'Jo')
.set('age', 41)
.set('pet', spot) // ⚠️ Should not accept GraphNode argument.
.setRef('pet', spot); // ✅ Correct.
const sam = new Person()
.set('name', 'Sam')
.set('age', 45)
.set('friend', jo) // ⚠️ Should not accept GraphNode argument.
.setRef('friend', jo); // ✅ Correct.
My problem is this — how can I define the GraphNode base class, when its generic types depend recursively on the GraphNode class? Here's my attempt:
Utility Types:
type Literal = null | boolean | number | string;
type LiteralAttributes<Base> = {
[Key in keyof Base]: Base[Key] extends Literal ? Base[Key] : never;
};
// ⚠️ ERROR — Generic type 'GraphNode<Attributes>' requires 1 type argument(s).
type RefAttributes<Base> = {
[Key in keyof Base]: Base[Key] extends infer Child
? Child extends GraphNode | null
? Child | null
: never
: never;
};
Abstract Graph Definition:
type GraphAttributes = {[key: string]: Literal | GraphNode | null};
abstract class GraphNode<Attributes extends GraphAttributes> {
public attributes = {} as LiteralAttributes<Attributes> | RefAttributes<Attributes>;
public get<K extends keyof LiteralAttributes<Attributes>>(key: K): Attributes[K] {
return this.attributes[key]; // ⚠️ Missing type information.
}
public set<K extends keyof LiteralAttributes<Attributes>>(key: K, value: Attributes[K]): this {
this.attributes[key] = value;
return this;
}
public getRef<K extends keyof RefAttributes<Attributes>>(key: K): Attributes[K] | null {
return this.attributes[key]; // ⚠️ Missing type information.
}
public setRef<K extends keyof RefAttributes<Attributes>>(key: K, value: Attributes[K] | null): this {
this.attributes[key] = value; // ⚠️ Missing type information.
return this;
}
}
That doesn't compile, and I've flagged the TS errors in comments above. I think that comes down to a basic problem – I want to extend the generic class later, with type arguments that refer to the generic class, and I'm not sure how to do that. I could live with not having strict type checking within the GraphNode class itself, but I really do want its subclasses to have strict type checking.
I've also created an example TypeScript playground reproducing this code with the compiler errors.
CodePudding user response:
There are quite a few issues in the original code which made it not behave the way you want. First, to get the keys of an object type T
whose values are assignable to a type V
, you can do something like this:
type KeysMatching<T, V> = {[K in keyof T]-?: T[K] extends V ? K : never}[keyof T];
This maps the properties from T
to either the key K
or never
depending on whether or not whether those properties are assignable to V
, and then immediately indexes into that mapped type with keyof T
to get the union of keys we care about.
We can specialize that to get the keys of T
whose properties are assignable to Literal
and those whose properties are assignable to some GraphNode<X>
type:
type LiteralKeys<T> = { [K in keyof T]-?: T[K] extends Literal ? K : never }[keyof T];
type RefKeys<T> = { [K in keyof T]-?: T[K] extends GraphNode<any> ? K : never }[keyof T]
So we can use LiteralKeys<A>
instead of your keyof LiteralAttributes<A>
which didn't work because keyof LiteralAttributes<A>
is always the same as keyof A
(you were mapping the bad property values to never
but that doesn't eliminate the key).
Now GraphNode
can be defined like this:
abstract class GraphNode<A extends Record<keyof A, Literal | GraphNode<any>>> {
public attributes: Partial<A> = {};
public get<K extends LiteralKeys<A>>(key: K): A[K] | undefined {
return this.attributes[key];
}
public set<K extends LiteralKeys<A>>(key: K, value: A[K]): this {
this.attributes[key] = value;
return this;
}
public getRef<K extends RefKeys<A>>(key: K): A[K] | undefined {
return this.attributes[key];
}
public setRef<K extends RefKeys<A>>(key: K, value: A[K] | undefined): this {
this.attributes[key] = value;
return this;
}
}
Note that you GraphNode<A>
is generic in the type parameter A
, and whenever you refer to GraphNode
you must specify the generic type parameter. You can't just say GraphNode
. If you don't know or care what the type parameter should be, you can use the any
type, like GraphNode<any>
. This will always work, but may end up allowing some things you don't want to allow. In this case it's probably fine.
A
is constrained to Record<keyof A, Literal | GraphNode<any>>
instead of {[k: string]: Literal | GraphNode<any>}
because we don't really want to require that A
have a string
index signature. By constraining A
to Record<keyof A, ...>
we are saying the keys can be whatever they happen to be.
The attributes
property is of type Partial<A>
. It's A
because we want to hold something very much like A
and not anything like LiteralAttributes<A> | RefAttributes<A>
. And we're using the Partial<T>
utility type to acknowledge that at any given time it might not have all of the properties of A
set; indeed it is initialized as {}
, which has no properties at all. This also means I have changed the get()
and getRef()
return types to include undefined
.
The get()
and set()
methods are generic in K extends LiteralKeys<A>
, while the getRef()
and setRef()
methods are generic in K extends RefKeys<A>
. For most reasonable A
types, LiteralKeys<A>
and RefKeys<A>
will be mutually exclusive and together make up all of keyof T
. If A
has any properties which are themselves unions or intersections of Literal
and GraphNode<any>
then this might not be true. I'm not worried about this here but if that does happen you can expect to run into some weird edge cases.
Let's make sure this behaves the way you want. Your subclasses and usage compile as expected, even resulting in this:
const jo = new Person()
.set('name', 'Jo')
.set('age', 41)
.set('pet', spot) // error! Argument of type '"pet"'
// is not assignable to parameter of type 'LiteralKeys<IPerson>'
.setRef('pet', spot);
const sam = new Person()
.set('name', 'Sam')
.set('age', 45)
.set('friend', jo) // error! Argument of type '"friend"'
// is not assignable to parameter of type 'LiteralKeys<IPerson>'
.setRef('friend', jo);
So it looks good!