Jake
Jake

Reputation: 58

Typescript - How can I conditionally define a type based on another property

I'm trying to conditionally set the return type of a function, based on a static value in the same type.

Given the following code, is it possible to have type checking work as I've specified in the comments?

IE, if const taskX: Task has the id: 'get-name', how can I make it so taskX.run() should return according to the type defined as Outputs['get-name']

type Outputs = {
  "get-name": {
    name: string;
  };
  "get-age": {
    age: number;
  };
  "get-favourite-fruits": ("apple" | "strawberry" | "melon")[];
};

type Task = {
  // This should always be a static property defined in `Outputs`
  id: keyof Outputs;
  // How can I make this conditional and return the type from `Outputs` based on `id`
  run: () => never; 
};

const taskOne: Task = {
  id: "get-name",
  // This should be valid
  run: () => {
    return { name: "frankenstein" };
  },
};

const taskTwo: Task = {
  id: "get-age",
  // This should also be valid
  run: () => {
    return { age: 22 };
  },
};

const taskThree: Task = {
  id: "get-favourite-fruits",
  // This should fail with
  // Type '("apple" | "banana")[]' is not assignable to type
  // '("apple" | "strawberry" | "melon")[]'
  run: () => {
    return ["apple", "banana"];
  },
};

Upvotes: 0

Views: 107

Answers (2)

jcalz
jcalz

Reputation: 330171

You want Task to be a discriminated union of the three different possible pairings of id and run. You can generate this programmatically from Outputs by mapping each property of Outputs to a value corresponding to this pairing, and then looking up all of the mapped properties to get the desired union:

type Task = {
  [K in keyof Outputs]: { id: K, run: () => Outputs[K] }
}[keyof Outputs];

/* produces
type Task = {
    id: "get-name";
    run: () => {
        name: string;
    };
} | {
    id: "get-age";
    run: () => {
        age: number;
    };
} | {
    id: "get-favourite-fruits";
    run: () => ("apple" | "strawberry" | "melon")[];
}*/

Then the rest of your code works as desired. Valid tasks are accepted:

const taskOne: Task = {
  id: "get-name",
  run: () => {
    return { name: "frankenstein" };
  },
}; // okay

const taskTwo: Task = {
  id: "get-age",
  run: () => {
    return { age: 22 };
  },
}; // okay

And invalid tasks are rejected:

const taskThree: Task = {
  id: "get-favourite-fruits",
  run: () => { // error!
    // Type '() => ("apple" | "banana")[]' is not assignable to type
    // '(() => { name: string; }) | (() => { age: number; }) | 
    // (() => ("apple" | "strawberry" | "melon")[])
    return ["apple", "banana"];
  },
};

const taskFour: Task = { // error!
//    ~~~~~~~~
// Type '{ id: "get-favourite-fruits"; run: () => { age: number; }; }'
// is not assignable to type 'Task'.
  id: "get-favourite-fruits",
  run: () => { 
    return { age: 123 };
  },
};

The errors for invalid tasks might be a bit different from the particular one you were expecting. For taskThree the compiler complains that run()'s return is incompatible with every possible Task member; "not only isn't it the right array, but it isn't even one of the right types for get-name or get-age!" For taskFour the compiler sees that run() returns an {age: number} but the id is get-favorite-fruits and complains that the whole value is wrong without highlighting any particular property.

Playground link to code

Upvotes: 2

Yuval
Yuval

Reputation: 1232

You can achieve that by declaring a type which is either of them and based on the id the compiler can tell which one of those it is.

type GetNameTask = {
    id: "get-name";
    run: () => {name: string};
}
type GetAgeTask = {
    id: "get-age";
    run: () => {number: string};
}
type GetFavoriteFruitsTask = {
    id: "get-favourite-fruits"
    run: () => ("apple" | "strawberry" | "melon")[];
}

type Task = GetNameTask | GetAgeTask | GetFavoriteFruitsTask

For more a clean systax you can also try this:

type GenericTask<TId, TOutput> = {
    id: TId;
    run: () => TOutput
}

type GetNameTask2 = GenericTask<"get-name", {name: string}>
type GetAgeTask2 = GenericTask<"get-age", {age: number}>
type GetFavoriteFruitsTask2 = GenericTask<"get-favourite-fruits", ("apple" | "strawberry" | "melon")[]>
type Task = GetNameTask2 | GetAgeTask2 | GetFavoriteFruitsTask2

Upvotes: 0

Related Questions