基于递归泛型接口定义一个TypeScript类

5

我正在为一个项目设置基础数据结构,希望拥有一个抽象的GraphNode基本对象,让其他许多对象继承它。每个GraphNode子类都有元数据,可能包括字面量(字符串、数字等)和对其他GraphNode派生类型的引用。

示例子类:

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> {}

示例用法:

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.

我的问题是这样的——当GraphNode基类的通用类型在递归依赖于GraphNode类时,我该如何定义它?以下是我的尝试:

实用类型:

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;
};

抽象图定义:

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;
  }
}

那段代码无法编译,我在上面的注释中标记了TS错误。我认为这归结于一个基本问题-我想稍后扩展泛型类,使用引用泛型类的类型参数,但我不确定如何做到这一点。我可以容忍GraphNode类本身没有严格的类型检查,但我真的希望它的子类有严格的类型检查。

我还创建了一个 TypeScript playground 的示例, 该示例链接如下:

这里有很多内容,这是一个 [mre](强调minimal)吗?为什么 LiteralAttributes<T> 的一些属性的类型是 never?你希望 attributes 保存 never 类型的值吗?为什么 attributes 是联合类型而不是交叉类型?你希望 attributes 只保存 A 的文字属性 或者 引用属性,而不是 同时 保存两者吗?我可能能够将其重构为编译通过的代码,但我希望这个示例能够更加简化。我会继续寻找。 - jcalz
嗨@jcalz-谢谢您的回复!我会看看是否可以进一步减少它。我确实希望attributes包含A的文字属性和A的引用属性的混合。我刚在TypeScript上找到了一个相关问题,并且根据我设置它的方式,它可能会出现错误。我不太担心nullundefined的区别,并且试图在这里概述它,因为我认为我稍后会知道如何解决它。 - Don McCurdy
这种方法是否符合您的需求?如果是的话,我可以写一篇答案。使用与无关问题的示例代码存在问题,会分散注意力;如果您不是在询问null vs undefined的问题,则有可能使对话偏离主题。无论如何,如果链接的方法不能满足某些用例,请告诉我(最好编辑示例以演示未满足的用例)。 - jcalz
1
@jcalz 在 TS Playground 中还有一个用于 URL 缩短的插件。 - captain-yossarian from Ukraine
1
是的,我使用那个插件,但告诉人们如何启用它比只使用相同的tsplay.dev网址不太清晰。 - jcalz
显示剩余5条评论
1个回答

5

原始代码存在许多问题,导致其无法按照您的意愿运行。首先,要获取值可分配给类型V的对象类型T的键,您可以像这样操作:

type KeysMatching<T, V> = {[K in keyof T]-?: T[K] extends V ? K : never}[keyof T];

这将从T映射属性到键Knever,具体取决于这些属性是否可分配给V,然后立即使用keyof T索引该映射类型,以获取我们关心的键的联合union

我们可以专门针对此进行特化,以获取可分配给Literal和可分配给某些GraphNode<X>类型的T的键。

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]

所以我们可以使用LiteralKeys<A>代替你的keyof LiteralAttributes<A>,因为keyof LiteralAttributes<A>总是与keyof A相同(你将错误的属性值映射到never,但这并不能消除键)。
现在可以这样定义GraphNode:
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;
  }
}

请注意,GraphNode<A> 中的 generic 类型参数为 A。每当您引用 GraphNode 时,都必须指定泛型类型参数。您不能只说 GraphNode。如果您不知道或不关心类型参数应该是什么,可以使用 any 类型,如 GraphNode<any>。这总是有效的,但可能会允许一些您不想允许的事情。在这种情况下,这样做可能没问题。

A约束Record<keyof A, Literal | GraphNode<any>>,而不是{[k: string]: Literal | GraphNode<any;>},因为我们并不想要求A拥有string索引签名。通过将A约束为Record<keyof A, ...>,我们在表明键可以是任何它们实际上是的东西。

attributes属性的类型为Partial<A>。这是因为我们想要保存类似于A而不是LiteralAttributes<A> | RefAttributes<A>的内容。我们使用Partial<T>实用类型来表示它可能并没有所有A属性,它被初始化为{},没有任何属性。这也意味着我已经更改了get()getRef()的返回类型,包括undefined

get()set()方法在泛型中使用 K extends LiteralKeys<A>,而getRef()setRef()方法在泛型中使用 K extends RefKeys<A>。对于大多数合理的A类型,LiteralKeys<A>RefKeys<A>将是互斥的,并且共同构成了keyof T的所有内容。如果A具有任何本身是LiteralGraphNode<any>的并集或交集的属性,则可能不是这样。我在这里不担心这个问题,但如果确实发生了,您可以期望遇到一些奇怪的边缘情况。


让我们确保它的行为符合您的期望。您的子类和用法都能按预期编译,甚至产生了这样的结果:

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); 

看起来不错!

代码游乐场链接


谢谢@jcalz!这个解决方案对我很有效,我很感激你详细的解释和一路上的评论/澄清。 - Don McCurdy

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接