Properly use Typescript Set<T> with intersecting types

89 Views Asked by At

I don't understand why the transpiler is complaining. Here are the basic type declarations:

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)

the only way I found was to modify the declaration of the Serializer with any as default type:

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

This flags me that there is probably a better way to preserve the minimum typing for the serializers Set iterator usage later on...

2

There are 2 best solutions below

0
On BEST ANSWER

The transpiler complains because you're effectively saying that you want a function that takes an SvgElement as input, but then you're trying to assign to it a function that takes an SvgPathElement.

This can't work - it would allow you to pass an SvgElement to your function that wants an SvgPathElement.

The formal term for this is "function parameters are contravariant".

Your code will require runtime type checks against the object being serialised anyway, so should you perhaps redefine your Serializer type without the generic? e.g.:

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

The compiler is complaining because your code, if allowed to compile, can cause type errors at runtime. For instance, if somebody were to do:

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

that would compile, since every member of the set serializers is declared to accept any kind of SvgElement - but that is not actually true, because each Serializer only accepts a particular type of element, and would fail with type errors at runtime if called with a different type.

So how do you write a correct type?

Before I answer that, let's imagine how serializers will be used, because I am a bit surprised that serializers is a Set. How are we going to find the right Serializer in this Set? It seems to me that Set is a really poor data structure for this purpose, and a Map, or a dictionary object, would be a more straightforward choice.

Therefore, I'd use something like this:

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

By declaring serializers with a mapped type, we can express that the types of the key and the value are related, i.e. that the serializer registered for a particular type only accepts values of that particular type. The generic lookupSerializer function prods the compiler to actually distribute the union, if you were to inline it, the compiler would not distribute the union, so the function is actually necessary in spite of its trivial implementation.