Home > Enterprise >  TypeScript conditional return type using an object parameter, and default values
TypeScript conditional return type using an object parameter, and default values

Time:05-10

I'm trying to write a function where the return type is specified by a string, but with a few extra twists thrown in:

  • The parameter is in an object
  • The parameter is optional
  • The object is optional

Can this be done?

Here's a stripped down version of what I'm trying to do. In the examples below, I show the different ways I want to be able to call my function fn() and have TypeScript correctly infer the return type:

interface NumberWrapper {
  type: "my_number";
  value: number;
}

const x1: number        = fn(1, { returnType: "bare" });
const x2: NumberWrapper = fn(1, { returnType: "wrapped" });
const x3: void          = fn(1, { returnType: "none" });
const x4: number        = fn(1, {});
const x5: number        = fn(1);

For x1, x2, x3, the function is called with a value for returnType.

For x4, no value is provided for returnType, and I want it to default to "bare". Similarly, for x5, the object isn't provided at all, and I want it to behave as though returnType is "bare".

(You might be wondering why I'm bothering to put returnType in an object, since it's just one parameter. In my actual use case, there are other parameters in the object.)

So far I've been able to get x1, x2, and x3 to work, but not x4 or x5. Here' what I have:

type ReturnMap<T extends "bare" | "wrapped" | "none"> = T extends "bare"
  ? number
  : T extends "wrapped"
  ? NumberWrapper
  : void;

function fn<T extends "bare" | "wrapped" | "none">(
  x: number,
  { returnType }: { returnType: T }
): ReturnMap<T> {
  if (returnType === "bare") {
    return x as ReturnMap<T>;
  } else if (returnType === "wrapped") {
    return { type: "my_number", value: x } as ReturnMap<T>;
  } else {
    return undefined as ReturnMap<T>;
  }
}

One thing I dislike is that each return statement has the form return x as ReturnMap<T> at the end. I'd like to not do that because the as causes it to lose some type safety.

But the bigger problem is that this doesn't work for x4 and x5. I've tried using default values in different ways, but haven't been able to get it to work.

CodePudding user response:

A quick fix is to make the generic type the whole argument rather than only the returnType property. Then it's just a (longer) conditional operator chain to get the types you want - though this still requires the ugly as assertions.

type Arg = undefined | { returnType?: "bare" | "wrapped" | "none" };
type ReturnMap<T extends Arg> =
  T extends { returnType: 'wrapped' }
    ? NumberWrapper
    : T extends { returnType: 'none' }
      ? void
      : number;

function fn<T extends Arg>(
  x: number,
  arg?: T
): ReturnMap<T> {
  if (!arg || !('returnType' in arg) || arg.returnType === 'bare') {
    return x as ReturnMap<T>;
  } else if (arg.returnType === 'wrapped') {
    return { type: "my_number", value: x } as ReturnMap<T>;
  } else {
    return undefined as ReturnMap<T>;
  }
}

const x1: number = fn(1, { returnType: "bare" });
const x2: NumberWrapper = fn(1, { returnType: "wrapped" });
const x3: void = fn(1, { returnType: "none" });
const x4: number = fn(1, {});
const x5: number = fn(1);

CodePudding user response:

Here's how I'd probably write your fn() function:

interface ReturnMap {
  bare: number,
  wrapped: NumberWrapper,
  none: void
}

function fn<K extends keyof ReturnMap = "bare">(
  x: number,
  { returnType = "bare" as K }: { returnType?: K } = { returnType: "bare" as K }
): ReturnMap[K] {
  return {
    get bare() { return x },
    get wrapped() { return { type: "my_number" as const, value: x } },
    get none() { return undefined }
  }[returnType]

}

Explanations:


For your "as ReturnMap<T>" issue, the problem is that the compiler cannot verify that your implementation satisfies the return type. The type ReturnMap<T> is a conditional type that depends on a generic type parameter T. In such cases the compiler says "I don't know what T is so I don't know what ReturnMap<T> it. Your implementation tests arg, but the compiler cannot use those tests to narrow the type parameter T. This is a known missing feature in TypeScript; see microsoft/TypeScript#33912 for more information.

I'd generally recommend refactoring to something the compiler can understand, such as indexing into an object using a generic key.

That's why ReturnMap is an actual mapping type with keys corresponding to your returnType values, and whose property values are the associated return types. Then fn() can be generic in K extends keyof ReturnMap and you will return ReturnMap[K] by looking up the returnType property of an object of type ReturnMap

Note that I've used getter methods so that the object of type ReturnMap doesn't have to pre-compute the result for every possible input.


For the "default" issue with thex4 and x5 cases, you want the behavior you get from the following JavaScript using both a function parameter default and a destructuring assignment default:

function fn(x, {returnType = "bare"} = {returnType: "bare"}) {}

That has exactly the runtime behavior you're looking for. Unfortunately when you give it typings, you'll need some type assertions to convince the compiler to accept it. That's because generics and default parameters don't really play very nicely together in TypeScript. See this question or this one for more information.

The defaults are handled by giving K a default type of "bare"; so if the compiler cannot infer K from your call to fn(), it will fall back to "bare". Similarly we make the returnType variable default to "bare" inside the implementation via the JavaScript destructuring assignment default and the function parameter default...

...but the compiler can't see that these defaults will happen together. That's why we use type assertions. By writing "bare" as K I'm saying that, in the cases where we use the default for returnType, then we will also be using the default for K. It's technically possible that someone could write fn<"none">(1), and in that case things would break. But probably nobody will do that, so we can just assume that K will be "bare" when defaults are used.


And there you go. It has the desired results:

const x1: number = fn(1, { returnType: "bare" });
const x2: NumberWrapper = fn(1, { returnType: "wrapped" });
const x3: void = fn(1, { returnType: "none" });
const x4: number = fn(1, {});
const x5: number = fn(1);

Playground link to code

  • Related