Don't Do Conditional Return Types in Typescript

06 August 2021

Or, more accurately: Typescript doesn't do conditional return types safely...

I have been working hard on a new Typescript interface for Meyda, and have been struggling. I'm sure I'll write a longer post with more learnings from that experience, but here's a quick one.

You may find yourself writing a function where the return type varies depending on what was passed in. For example:

function myFunction(a: boolean): /* return type? */ {}

In that example, I might want the function to return a number if the boolean is true, but a string if the boolean is false. In this scenario, one might reach for Typescript's Conditional Types feature. Briefly, conditional types are like ternary expressions in the type domain.

// Value domain ternary expression
const x = typeof "my string" === "boolean" ? "good" : "bad"

The predicte returns false, because "my string" is a string, not a boolean and so the false side of the ternary is selected.

// Type domain ternary expression (conditional type)
type T = "my string" extends boolean ? "good" : "bad"

Similarly, the literal type "my string" (which is a type, not a value) is not assignable to (doesn't extend) the type boolean, therefore the false side of the conditional type is returned and assigned to the type alias T.

This should help us tell Typescript that our function returns one type if the arg is true and another type if the arg is false.

function myFunction<T extends boolean>(a: T): T extends true ? number : string {
  // ...implementation
}

There's a little additional syntax here to enable the conditional type, which I'll quickly explain: we introduce a Generic type parameter for the function, not because we want it to take more than a boolean, but because we need to refer to the same type T in multiple places: the signature, and the return type. In fact, the generic is T extends boolean, which limits the type parameter to a boolean. Of course, our parameter is then of type T, and we focus on the conditional return type: T extends true ? number : string. That type means "if the value we pass in is specifically only a true, then we'll return a number. Since the only other member of boolean is false, we'll return a string in that case".

This seems to satisfy the compiler, at first glance. Indeed the Typescript documentation at time of writing includes a similar example of using a conditional type in the function return type position. But there's a problem.

When we implement the function, we get a few complaints from the compiler.

function myFunction<T extends boolean>(a: T): T extends true ? number : string {
  if (a) {
    return 0;
  }
  return "";
}

Both return statements are invalid!

Type 'number' is not assignable to type 'T extends true ? number : string'.ts(2322)
Type 'string' is not assignable to type 'T extends true ? number : string'.ts(2322)

It turns out that Typescript has no way of assigning a concrete return value to a Conditional Return Type. That's because neither return statement in the function knows about T - so they have no way of saying which side of the case they're on.

So what are the options?

You could write as any after each return statement. As long as you know that you're throwing out any type safety when you use any, I'll trust that you know your context well enough to make a judgement call. I would try to avoid this at all costs.

You could use function overloading - almost. See this example:

function myFunction(a: true): number;
function myFunction(a: false): string;

function myFunction(a: boolean): number | string {
  if (a) {
    return 0;
  }
  return "";
}

But there's a subtle bug that shows up in more complex situations.

declare function myFunction(a: number): number;
declare function myFunction(a: string): string;
function myFunction(a: number | string): number | string {
  if (typeof a === "number") {
    return 0;
  }
  return "";
}

The type system doesn't enforce that your implementation returns a value of the same type it received. Your code could receive a number and return a string, and the type system couldn't catch that.

If you add a generic to handle that case as follows:

function myFunction(a: number): number;
function myFunction(a: string): string;
function myFunction<T extends number | string>(a: T): T{
  if (typeof a === "number") {
    return 0;
  }
  return "";
}

You're back to square one - neither return value is assignable to the annotated implementation return value.

After this, I'm all out of ideas. I'm not sure that it's possible to achieve this effect in Typescript. There is an issue tracking some changes that might enable this. There's also an issue tracking the removal of the conditional types example that suggests they can be used in this way. If I've missed a cool way to do this, please do let me know on Mastodon or Twitter! Thanks for reading!