网站搜索

泛型 - 正确使用 Typescript Set<T> 与交叉类型


我不明白为什么转译器会报错。以下是基本的类型声明:

export enum SvgElementType {
  path = "path",
  circle = "circle",
}

export type SvgElement = {
  id: number;
  type: SvgElementType;
};

export type SvgPathElement = SvgElement & {
  type: SvgElementType.path;
  d: string;
};

export type SvgCircleElement = SvgElement & {
  type: SvgElementType.circle;
  cx: number;
  cy: number;
  radius: number;
};

export type Serializer<E extends SvgElement = SvgElement> = (element: E) => string;

const pathSerializer: Serializer<SvgPathElement> = e => "";
const circleSerializer: Serializer<SvgCircleElement> = e => "";

const serializers: Set<Serializer> = new Set();
serializers.add(pathSerializer); // <<<--- transpile error

// full TS error
// Argument of type 'Serializer<SvgPathElement>' is not assignable to parameter of type 'Serializer<SvgElement>'.
//  Type 'SvgElement' is not assignable to type 'SvgPathElement'.
//    Property 'd' is missing in type 'SvgElement' but required in type '{ type: SvgElementType.path; d: string; }'.ts(2345)

我找到的唯一方法是将 Serializer 的声明修改为使用 any 作为默认类型:

export type Serializer<E extends SvgElement = any> = (element: E) => string;

这提醒我,可能有更好的方法来在之后使用 serializers 集合迭代器时保持最小的类型定义...

答案#1

转译器报错是因为你实际上是在说你想要一个以 SvgElement 作为输入的函数,但随后你却试图将一个以 SvgPathElement 作为输入的函数赋给它。

这行不通——它会允许你将一个 SvgElement 传递给需要 SvgPathElement 的函数。

这个正式术语是“函数参数是逆变的”。

您的代码无论如何都需要对正在序列化的对象进行运行时类型检查,所以您是否应该考虑重新定义您的 Serializer 类型而不使用泛型?例如:

export type Serializer = (element: SvgElement) => string;

答案#2

编译器正在报错,因为如果允许你的代码编译,它可能会在运行时导致类型错误。例如,如果有人这样做:

function serialize(e: SvgElement) {
  for (const s of serializers) {
    return s(element);
  }
}

那将可以编译,因为集合 serializers 的每个成员都声明为接受任何类型的 SvgElement——但实际上并非如此,因为每个 Serializer 只接受特定类型的元素,如果用不同类型的元素调用,将在运行时出现类型错误。

那么你如何写出正确的类型?

在我回答之前,让我们先想象一下 serializers 将如何使用,因为我有点惊讶 serializers 是一个 Set。我们如何在这个 Set 中找到合适的 Serializer?在我看来,Set 对于这个目的来说是一个非常糟糕的数据结构,而使用 Map 或字典对象会是一个更直接的选择。

因此,我会使用类似这样的东西:

export type SvgElementBase<T extends string> = {
  id: number;
  type: T;
}

export type SvgPathElement = SvgElementBase<"path"> & {
  d: string;
};

export type SvgCircleElement = SvgElementBase<"circle"> & {
  cx: number;
  cy: number;
  radius: number;
};

export type SvgElement = SvgPathElement | SvgCircleElement;
export type SvgElementType = SvgElement["type"];

export type Serializer<E extends SvgElement> = (e: E) => string;
export type Serializers = {[T in SvgElementType]: Serializer<SvgElement & SvgElementBase<T>>}

const serializers: Serializers = {
  path: (e) => `path ${e.d}`,
  circle: (e) => `circle at (${e.cx},${e.cy}) with radius ${e.radius}`,
}

function lookupSerializer<E extends SvgElement>(type: E["type"]): Serializer<E> { // the things we do for type safety
  return serializers[type];
}

export function serialize(element: SvgElement) {
  return lookupSerializer(element.type)(element);
}

通过使用映射类型声明 serializers,我们可以表示键和值的类型是相关的,也就是说,为特定类型注册的序列化器只接受该特定类型的值。通用的 lookupSerializer 函数会促使编译器实际分配联合类型,如果你将其内联,编译器不会分配联合类型,因此尽管函数实现很简单,仍然是必要的。