Ron
Ron

Reputation: 6735

Use conditional type in function

I am trying to use conditional type to link two parameters in a function, but I couldn't sort out to link 2 parameters when defining a function. Below is the simplified sample or the live version.

type Circle = { type: "circle"; data: { radius: number } };

type Rect = { type: "rect"; data: { width: number; height: number } };

type DefinedShape = Circle | Rect;

type FilterShapeByType<Shape, Type> = Shape extends { type: Type }
  ? Shape
  : never;

type DrawShape = <T extends DefinedShape["type"]>(
  type: T,
  data: FilterShapeByType<DefinedShape, T>["data"]
) => void;

let drawShape1: DrawShape = () => {};
drawShape1("rect", { height: 12, width: 22 }); // pass
drawShape1("rect", { height: 12, width: 22, radius: 23 }); // failed, radius is not defined
drawShape1("circle", { radius: 12 }); // pass
drawShape1("circle", { radius: 12, len: 23 }); // failed, len is not defined

const drawShape2: DrawShape = (type, data) => {
  switch (type) {
    case "circle":
      console.log(data.radius); // failed, Property 'radius' does not exist on type '(FilterShapeByType<Circle, T> | FilterShapeByType<Rect, T>)["data"]'.
      break;
    case "rect":
      console.log(data.height, data.width);
      break;
  }
};

the original goal is to link the two parameters in DrawShape function, meaning when type is circle, the data should be {radius: number}. I use the conditional type FilterShapeByType to sort it out.

All good before I try to define a function drawShape2. I am expecting when I use switch for the type for circle, the typescript should smart enough to assume data should be the Circle['data'], however it didn't. Wondering is there easy to make it automatically?

Playground

Upvotes: 3

Views: 123

Answers (3)

0Valt
0Valt

Reputation: 10345

The Why

That's not how the force TypeScript works.

Let's take a closer look at what the type of data is in the drawShape2 signature:

(parameter) data: (FilterShapeByType<Circle, T> | FilterShapeByType<Rect, T>)["data"]

What does it tell us? That the type of data depends on what T is resolved to. In your case, T is constrained to DefinedShape["type"], or, inlined, "circle" | "rect" (off-note: unless you want to confuse people, please, change the type to something more customary like kind).

Until you call the function with either "circle" or "rect" as type, T stays unresolved. With that said, let's test what is the actual type of data given the union "circle" | "rect":

type testFilter = FilterShapeByType<DefinedShape, "circle"|"rect">["data"];
// type testFilter = {
//     radius: number;
// } | {
//     width: number;
//     height: number;
// }

It should become obvious why you can't access either of the properties: because you can only access members without type guards if they are present on all constituents of the union (see discriminated unions). switch statement does not matter here as the compiler is unable to narrow the union due to unresolved T.

If you call the function, you will see there is no error:

drawShape2("circle", { radius:5 }) //OK, no error

The What

Now, what could be done to solve this?

There are several approaches, most are outlined in other answers, I would only like to mention that if you are in control of the public contract, use the DefinedShape directly since information necessary for narrowing is contained in type property:

const drawShape3 = (shape: DefinedShape) => {
  switch(shape.type) {
    case "circle": {
      console.log(shape.data.radius); //OK
      break;
    }
    case "rect": {
      console.log(shape.data.height, shape.data.width); //OK
      break
    }
  }
};

Upvotes: 1

Owl
Owl

Reputation: 6853

I'm curious of why your code isn't working. Looks like it should work, but it doesn't! I might have missed something, or it's a limitation from TypeScript.

Either way, here's a workaround that I prefer (using type guard):

type Shapes = {
  circle: { radius: number },
  rect: { width: number; height: number }
}

type ShapeTypes = keyof Shapes;

type DrawShape = <T extends ShapeTypes>(type: T, data: Shapes[T]) => void;

const isShapeOf = <T extends ShapeTypes>(expected: T, type: string, data: any): data is Shapes[T] => {
  return expected === type;
}

const drawShape: DrawShape = (type, data) => {
  if (isShapeOf("circle", type, data)) {
    data.radius; // pass
  } else if (isShapeOf("rect", type, data)) {
    data.radius; // radius does not exist
    data.width; // pass
    data.height; // pass
  }
};

drawShape("rect", { height: 12, width: 22 }); // pass
drawShape("rect", { height: 12, width: 22, radius: 23 }); // failed, radius is not defined
drawShape("circle", { radius: 12 }); // pass
drawShape("circle", { radius: 12, len: 23 }); // failed, len is not defined

Typescript Playground

Upvotes: 2

There is one trick. Don't tell anybody :D

Just use named tuples


  function drawShape2(...args: [type: 'circle', data: Circle['data']] | [type: 'rect', data: Rect['data']]) {
    switch (args[0]) {
      case "circle":
        const [type1, data1] = args;
        break;
      case "rect":
        const [type2, data2] = args
        break;
    }
  };

Upvotes: 2

Related Questions