0

I have the following code:

const enum ShapeType {
  Circle,
  Rectangle
}

class Shape {
  constructor(public shapeType: ShapeType) {}
}

class Circle extends Shape {
  constructor(public x: number, public y: number, public r: number) {
    super(ShapeType.Circle);
  }
}

class Rectangle extends Shape {
  constructor(public x: number, public y: number, public w: number, public h: number) {
    super(ShapeType.Rectangle);
  }
}

function handleRectangleRectangleCollision(r1: Rectangle, r2: Rectangle) {
  return Helpers.doRectanglesCollide(r1.x, r1.y, r1.w, r1.h, r2.x, r2.y, r2.w, r2.h)
}

function handleRectangleCircleCollision(r: Rectangle, c: Circle) {
  return Helpers.circleRectangleCollision(c.x, c.y, c.r, r.x, r.y, r.w, r.h);
}

function handleCircleCircleCollision(c1: Circle, c2: Circle) {
  return Helpers.circlesCollide(c1.x, c1.y, c1.r, c2.x, c2.y, c2.y);
}

function handleCircleRectangleCollision(c: Circle, r: Rectangle) {
  return Helpers.circleRectangleCollision(c.x, c.y, c.r, r.x, r.y, r.w, r.h);
}

export let colliderMapping = {
  [ShapeType.Rectangle]: {
    [ShapeType.Rectangle]: handleRectangleRectangleCollision,
    [ShapeType.Circle]: handleRectangleCircleCollision
  },
  [ShapeType.Circle]: {
    [ShapeType.Circle]: handleCircleCircleCollision,
    [ShapeType.Rectangle]: handleCircleRectangleCollision
  }
}

function doShapesCollide(s1: Shape, s2: Shape) {
  let colliderFn = colliderMapping[s1.shapeType][s2.shapeType];

  return colliderFn(s1, s2);
}

And I am getting an error on the last last:

return colliderFn(s1, s2);

Argument of type 'Shape' is not assignable to parameter of type 'Rectangle & Circle'.
  Type 'Shape' is missing the following properties from type 'Rectangle': x, y, w, h

I understand why I'm getting the error (I think), but I don't know how to solve it. I'm basically trying to implement a clean way of double-dispatch by having a mapping variable, such that every combination of shapes will return a valid function that I can call to see if they collide.

Is there any way to do this? If so, how?

Ryan Peschel
  • 11,087
  • 19
  • 74
  • 136

2 Answers2

0

Please take a look on my article

COnsider this super simple example:

type A = {
  check: (a: string) => string
}

type B = {
  check: (a: number) => number
}

type C = {
  check: (a: symbol) => number
}

type Props = A | B | C;
declare var props:Props;

props.check() // (a: never) => string | number

Why check expects never and not a union of all possible types?

Because function arguments are in contravariant position, they are merged into never because string & number & symbol is never;

Try to change type of check argument to some object:

type A = {
    check: (a: { a: 1 }) => string
}

type B = {
    check: (a: { b: 1 }) => number
}

type C = {
    check: (a: { c: 1 }) => number
}

type Props = A | B | C;
declare var props: Props;

//(a: { a: 1;} & { b: 1;} & { c: 1;}) => string | number
props.check()

It is clear that you have intersection of all possible arguments type.

There are several workarounds.

You can add conditional statements:

function doShapesCollide(s1: Shape, s2: Shape) {
    if (s1.shapeType === ShapeType.Circle && s2.shapeType === ShapeType.Circle) {
        let colliderFn = colliderMapping[s1.shapeType][s2.shapeType];
        return colliderFn(s1, s2); // should be ok
    }
}

Above approach still causes a compilation error because s1 is a Shape and colliderFn expects Circle. Circle is a subtype of Shape and is more specific - hence it does not work.

In order to make it work, you should add another one condition:


function doShapesCollide(s1: Shape | Circle, s2: Shape | Circle) {
    if (s1.shapeType === ShapeType.Circle && s2.shapeType === ShapeType.Circle) {
        let colliderFn = colliderMapping[s1.shapeType][s2.shapeType];
        if (s1 instanceof Circle && s2 instanceof Circle) {
            return colliderFn(s1, s2); // should be ok
        }
    }

}

It works but it is ugly. Is not it?

You can also create several typeguards which makes code cleaner but adds more business logic.

Or you can convert union of functions to intersections, in other words you can produce function overloading.

const enum ShapeType {
    Circle,
    Rectangle
}

class Shape {
    constructor(public shapeType: ShapeType) { }
}

class Circle extends Shape {
    constructor(public x: number, public y: number, public r: number) {
        super(ShapeType.Circle);
    }
}

class Rectangle extends Shape {
    constructor(public x: number, public y: number, public w: number, public h: number) {
        super(ShapeType.Rectangle);
    }
}

function handleRectangleRectangleCollision(r1: Rectangle, r2: Rectangle) {
}

function handleRectangleCircleCollision(r: Rectangle, c: Circle) {
}

function handleCircleCircleCollision(c1: Circle, c2: Circle) {
}

function handleCircleRectangleCollision(c: Circle, r: Rectangle) {
}

export let colliderMapping = {
    [ShapeType.Rectangle]: {
        [ShapeType.Rectangle]: handleRectangleRectangleCollision,
        [ShapeType.Circle]: handleRectangleCircleCollision
    },
    [ShapeType.Circle]: {
        [ShapeType.Circle]: handleCircleCircleCollision,
        [ShapeType.Rectangle]: handleCircleRectangleCollision
    }
}

// credits goes to https://stackoverflow.com/a/50375286
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
    k: infer I
) => void
    ? I
    : never;

function doShapesCollide(s1: Shape, s2: Shape) {
    let colliderFn = colliderMapping[s1.shapeType][s2.shapeType];
    type Overload =
        & UnionToIntersection<typeof colliderFn>
        & ((r1: Rectangle | Circle | Shape, r2: Rectangle | Circle | Shape) => void)
    const overloaded = colliderFn as Overload
    return overloaded(s1, s2); // should be ok
}

Playground

Above change does not require you to change your business logic.

0

I noticed that handleRectangleCircleCollision and handleCircleRectangleCollision doing the same stuff but only changing the order of the passed arguments.

function handleRectangleCircleCollision(r: Rectangle, c: Circle) {
  return Helpers.circleRectangleCollision(c.x, c.y, c.r, r.x, r.y, r.w, r.h);
}

function handleCircleRectangleCollision(c: Circle, r: Rectangle) {
  return Helpers.circleRectangleCollision(c.x, c.y, c.r, r.x, r.y, r.w, r.h);
}

You´r also using Classes and inheritance (OOP aproach) with functions outside of the classes.

I tried to reduce the code and keep it simple. Over here is a solution that make use of typeguards. I also replaced the classes with intersection types.

type Point = { x: number; y: number; }
type Rectangle = { w: number; h: number; } & Point
type Circle = { r: number } & Point
type Shape = Rectangle | Circle

function isCircle(shape: Shape): shape is Circle {
  return 'r' in shape;
}  
function handleShapeCollision(shapeA: Shape, shapeB: Shape) {
  if (isCircle(shapeA)) 
  return handleCircleCollision(shapeA, shapeB);
  return handleRectangleCollision(shapeA, shapeB);
}
function handleCircleCollision(circle: Circle, shape: Shape) {
  if (isCircle(shape)) 
  return Helpers.circlesCollide(circle.x, circle.y, circle.r, shape.x, shape.y, shape.y);
  return Helpers.circleRectangleCollision(circle.x, circle.y, circle.r, shape.x, shape.y, shape.w, shape.h);
}
function handleRectangleCollision(r: Rectangle, shape: Shape) {
  if (isCircle(shape)) 
  return Helpers.circleRectangleCollision(shape.x, shape.y, shape.r, r.x, r.y, r.w, r.h);
  return Helpers.doRectanglesCollide(r.x, r.y, r.w, r.h, shape.x, shape.y, shape.w, shape.h)
}

Over here is a TypescriptPlayground. Feel free to play around.

Martin Godzina
  • 1,470
  • 11
  • 17