DEV Community

Chris
Chris

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 (14)

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 • 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

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

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

Sure, glad I could help :)

Collapse
 
corners2wall profile image
Corners to wall

Seems as strange hack

Collapse
 
zirkelc profile image
Chris

May I ask why?

Collapse
 
corners2wall profile image
Corners to wall

Not