DEV Community

Chris Cook
Chris Cook

Posted on • Updated on • Originally published at zirkelc.dev

Conditional Return Types: How to Return the Right Type

Conditional return types are a powerful feature of TypeScript that allow you to specify different return types for a function based on the type of the arguments. This can be useful when you want to enforce type safety and ensure that the return type matches the expected type.

For example, consider a function for a custom plus operator with two arguments. If the arguments are strings, the two strings are concatenated and returned. If the arguments are numbers, it adds the two numbers together and returns the sum.

function plus<T extends string | number>(a: T, b: T): T extends string ? string : number {
  if (typeof a === 'string' && typeof b === 'string') {
    return (a + b) as string;
  }

  if (typeof a === 'number' && typeof b === 'number') {
    return (a + b) as number;
  }

  throw new Error('Both arguments must be of the same type');
}

const result1 = plus(1, 2); // result1 has type number
const result2 = plus('Hello ', 'World'); // result2 has type string
Enter fullscreen mode Exit fullscreen mode

In this code, the plus function takes two arguments of type T, which can be either a string or a number. The function then uses a conditional return type to specify that the return type should be a string if T extends string, and a number otherwise.

However, TypeScript has trouble correctly inferring the return type within the function implementation. The compiler reports errors on lines 3 and 7, although the return type is correctly inferred on lines 14 and 15 when the function is called.

Image description
TypeScript playground

The problem is that the type T is used in both the function signature and the conditional return type, which can lead to a circular reference error. To fix this, we need to use a separate type parameter R for the return type:

function plus<T extends string | number, R = T extends string ? string : number>(a: T, b: T): R {
  if (typeof a === 'string' && typeof b === 'string') {
    return (a + b) as R;
  }

  if (typeof a === 'number' && typeof b === 'number') {
    return (a + b) as R;
  }

  throw new Error('Both arguments must be of the same type');
}

const result1 = plus(1, 2); // result1 has type number
const result2 = plus('Hello ', 'World'); // result2 has type string
Enter fullscreen mode Exit fullscreen mode

In this example, the R type parameter is used to specify the return type based on the conditional type. This avoids the circular reference error and allows the function to be correctly typed.

Image description
TypeScript playground


I hope you found this post helpful. If you have any questions or comments, feel free to leave them below. If you'd like to connect with me, you can find me on LinkedIn or GitHub. Thanks for reading!

Top comments (16)

Collapse
 
joelbonetr profile image
JoelBonetR 🥇

While it's somewhat funny to see hacks with TS Types I've never found use-cases for this thingies in real life.

I'd rather do:

const concat = (str1: string, str2: string) => str1.concat('', str2);

const sum = (num1: number, num2: number) => num1 + num2;
Enter fullscreen mode Exit fullscreen mode

Which make each more reusable, less prone to error and overall more readable.

I've let a like, tho 😁 cheers!

Collapse
 
zirkelc profile image
Chris Cook • Edited

To be honest, my example was rather abstract in the context of JavaScript, since there is no logic of operator overloading like in other programming languages like C#.

I was just looking for an illustrative example that could make sense from a purely logical point of view, and I ended up with this example :D

Nevertheless, I prepared another example that makes more sense in daily life. It is a serialization function that converts an object into different types (String, Uin8tArray, Stream) depending on the generic type parameter TFormat. The return type is derived depending on the value of TFormat:

type JsonFormat = { type: "json" };
type BinaryFormat = { type: "binary" };
type StreamFormat = { type: "stream" };
type Format = JsonFormat | BinaryFormat | StreamFormat;

type SerializeReturnType<TFormat extends Format> = 
  TFormat extends JsonFormat 
  ? string
  : TFormat extends BinaryFormat
  ? Uint8Array
  : TFormat extends StreamFormat
  ? ReadableStream<Uint8Array>
  : never;

function serialize<TFormat extends Format>(
  obj: Record<string, unknown>,
  format: TFormat,
): SerializeReturnType<TFormat> {

  if (format.type === "json") {
    const jsonString = JSON.stringify(obj);
    return jsonString as SerializeReturnType<TFormat>;
  }

  if (format.type === "binary") {
    const textEncoder = new TextEncoder();
    const uint8array = textEncoder.encode(JSON.stringify(obj));
    return uint8array as SerializeReturnType<TFormat>;
  }

  if (format.type === "stream") {
    const textEncoder = new TextEncoder();
    const uint8array = textEncoder.encode(JSON.stringify(obj));
    const stream = new ReadableStream({
      start(controller) {
        controller.enqueue(uint8array);
        controller.close();
      },
    });

    return stream as SerializeReturnType<TFormat>;
  }

  throw new Error("Invalid format");
}

const s1 = serialize({ a: 1, b: 2 }, { type: "json"});    // return type is string
const s2 = serialize({ a: 1, b: 2 }, { type: "binary"});  // return type is Uint8Array
const s3 = serialize({ a: 1, b: 2 }, { type: "stream"});  // return type is Stream
Enter fullscreen mode Exit fullscreen mode

TypeScript playground

Thank for your like! :)

Collapse
 
joelbonetr profile image
JoelBonetR 🥇

Anytime! 😁

This one looks amazing, I believe it's pretty clear what the intended message was with this example, thank you 🎖️

Collapse
 
balazssoltesz profile image
Balazs Soltesz

That is what function overloading is for. tutorialsteacher.com/typescript/fu...

Collapse
 
zirkelc profile image
Chris Cook

Yes, function overloading would also work in this case. I posted about function overloading in my previous post.

However, this post and example should simply illustrate that there are other ways that may be less verbose than overloading a function with multiple signatures.

Collapse
 
vindecodex profile image
Vincent Villaluna

No need for conditionals:

const fn = <T>(a: T, b: T): T => returnValue as T;
Enter fullscreen mode Exit fullscreen mode

usage:

const num = fn<number>(1, 2);
const str = fn<string>('1', '2');
const custom = fn<CustomType>(valA, valB);
Enter fullscreen mode Exit fullscreen mode
Collapse
 
vindecodex profile image
Vincent Villaluna

Doing conditionals with types makes the function more dependent with types, the more Types the more if blocks you will add into your function

Collapse
 
alansikora profile image
Alan Sikora

Type 'string' is not assignable to type 'T extends string ? string : number'

Why I'm I getting this error?

Image description

Is there as specific config I need?

Collapse
 
zirkelc profile image
Chris Cook

I had the same issue. It seems that the generic parameter T causes a circular reference when it is used as function type parameter and return type.

You can refer to my second example that fixes this issue: Playground

Collapse
 
alansikora profile image
Alan Sikora

That works Chris, thank you very much for the quick reply!

Thread Thread
 
zirkelc profile image
Chris Cook

Sure, glad I could help :)

Collapse
 
nyngwang profile image
Ning Wang • Edited

Apologies for raining on your parade, but if your post is about "I made conditional return type work" because of "I solved the cause -- circular reference", then you're wrong.

  1. The key part is that TypeScript simply doesn't unpack that T extends string? string : number for you when you're writing the function body. I'm not sure about the reason for it. This makes either as number or as string cannot satisfy the type T extends string? string : number.
  2. Based on 1., to fix it you just mark those returns as any. Now, you get what you want from the returned object: it's either number or string depending on the argument.
  3. So, why your workaround can work? By extracting the conditional return type into a type parameter R, and explicitly marking the return expression as R, you made the return type of that return the same as that of your function signature: both are R. So no error.

So, like it or not, both cases work with as any, playground. But I found your way a little bit counter-intuitive as you made the return type one of those generic parameters and always let TypeScript fill-out it for you. In that case, it shouldn't be a parameter.

I hope this could help.

Collapse
 
mbhaskar98 profile image
Bhaskar

Nice to know information.

Interestingly for the original example Example compiler doesn't give any if return type is something like this - return true as R;.

Check out here - Playground

Looks like compiler allowing anything to pass.

For these second example compiler gives error - Serialization, probably due to type being inferred with each return statement?

Collapse
 
corners2wall profile image
Corners 2 Wall

Seems as strange hack

Collapse
 
zirkelc profile image
Chris Cook

May I ask why?

Collapse
 
corners2wall profile image
Corners 2 Wall

Not