Home > OS >  TypeScript: Can I use type generics to return a different value and avoid defining the same function
TypeScript: Can I use type generics to return a different value and avoid defining the same function

Time:12-25

I have a React project with a function that returns the user's position and it can either return a location object, GeoLocation, or an error object if such occurs, GeoError:

export function getPosition(): GeoLocation | GeoError {
  // ...
}

The thing is, most usages of this function only care about whether there is a location object or not, and to avoid checking the type of the returned object I added a default parameter that indicates whether the caller wants the error:

export function getPosition(withError: boolean = false): GeoLocation | GeoError | undefined {
  let position: GeoLocation = ...;
  let positionError: GeoError = ...;

  if (!position) {
    if (withError)
      return positionError;
    else
      return undefined;
  }
  return position;
}

Then the callers that don't want the GeoError can just cast and avoid checking the type of the return value:

let userPosition = getPosition() as GeoLocation | undefined;
if (!userPosition) {
  alert("No position!")
  return;
}
// ... do something with position

But I want to avoid casting, so I figured out having two functions is nice:

function getPosition(): GeoLocation | undefined;
function getPositionOrError(): GeoLocation | GeoError;

but I don't want to write the same location retrieval logic twice with different implementations for the return value, so I did something like this:

function _getPosition(withError: boolean = false): GeoLocation | GeoError | undefined {
  let position: GeoLocation = ...;
  let positionError: GeoError = ...;

  if (!position) {
    if (withError)
      return positionError;
    else
      return undefined;
  }
  return position;
}
export function getPositionOrError(): GeoLocation | GeoError {
  return _getPosition(true) as GeoLocation | GeoError;
}
export function getPosition(): GeoLocation | undefined {
  return _getPosition(false) as GeoLocation | undefined;
}

Which works but seems kinda ugly with the cast in my opinion, so I wondered if it's possible to use type generics to let the compiler help and infer the type:

function _getPosition<WError=false>(): GeoLocation | WError ? GeoError : undefined {
  let position: GeoLocation = ...;
  let positionError: GeoError = ...;

  if (!position) {
    if (WError)
      return positionError;
    else
      return undefined;
  }
  return position;
}

but this unfortunately doesn't work, and I don't know what search terms I can lookup to do it correctly or whether it's possible at all.

Thanks!

CodePudding user response:

How about using overloading?

function getPosition(): GeoLocation | undefined;
function getPosition(withError: true): GeoLocation | GeoError;
function getPosition(withError: boolean = false): GeoLocation | GeoError | undefined {
  let position: GeoLocation = ...;
  let positionError: GeoError = ...;

  if (!position) {
    if (withError)
      return positionError;
    else
      return undefined;
  }
  return position;
}

CodePudding user response:

As @Andreas's answer states, you could use overloads; this is straightforward to do:

function getPosition(withError?: false): GeoLocation | undefined;
function getPosition(withError: true): GeoLocation | GeoError;
function getPosition(withError: boolean = false): GeoLocation | GeoError | undefined {
  let position: GeoLocation = null!;
  let positionError: GeoError = null!;
  if (!position) {
    if (!withError) // oops!
      return positionError;
    else
      return undefined;
  }
  return position;
}

const y = getPosition(true); // GeoLocation | GeoError;
const n = getPosition(false); // GeoLocation | undefined;
const n2 = getPosition(); // GeoLocation | undefined;

The main drawback is that the compiler doesn't really check the implementation very well; you could, for example, flip a boolean in your implementation, and the compiler wouldn't catch it. In fact, I did that above (// oops!) and there's no compiler error. This is still a reasonable way forward, though.


If you want to use generics the way you're describing in your question, then you're looking to make the return type of the function a conditional type. So the call signature could be:

declare function getPosition<B extends boolean = false>(
  withError: B = false as B
): GeoLocation | (B extends true ? GeoError : undefined);

So the B type parameter would be true if withError is true, and false if withError is false, or if it is omitted (because the default type parameter is false, and so is the default function parameter). And the return type is the union of GeoLocation with the conditional type B extends true ? GeoError : undefined.

Note that this also allows you to pass in a boolean for withError, and the return type will be GeoLocation | GeoError | undefined. Like this:

const y = getPosition(true); // GeoLocation | GeoError;
const n = getPosition(false); // GeoLocation | undefined;
const n2 = getPosition(); // GeoLocation | undefined;
const yn = getPosition(Math.random() < 0.5) // GeoLocation | GeoError | undefined

Unfortunately implementing generic functions with conditional return types is annoying. The compiler still cannot verify that what you are doing in the implementation is safe, and this time it complains about everything you return instead of accepting everything you return. That is, you'll get false warnings instead of silent failures:

export function getPosition<B extends boolean = false>(
  withError: B = false as B
): GeoLocation | (B extends true ? GeoError : undefined) {
  let position: GeoLocation = null!;
  let positionError: GeoError = null!;

  if (!position) {
    if (withError)
      return positionError as (B extends true ? GeoError : undefined);
    else
      return undefined as (B extends true ? GeoError : undefined);
  }
  return position;
}


export function getPosition<B extends boolean = false>(
  withError: B = false as B
): GeoLocation | (B extends true ? GeoError : undefined) {
  let position: GeoLocation = null!;
  let positionError: GeoError = null!;

  if (!position) {
    if (withError)
      return positionError; // error!
    else
      return undefined; // error!
  }
  return position;
}

This is a limitation of TypeScript; see the feature request at microsoft/TypeScript#33912 asking for something better. For now, you need something like type assertions to suppress the error (you call this "casting"):

export function getPosition<B extends boolean = false>(
  withError: B = false as B
):
  GeoLocation | (B extends true ? GeoError : undefined) {
  let position: GeoLocation = null!;
  let positionError: GeoError = null!;

  if (!position) {
    if (withError)
      return positionError as (B extends true ? GeoError : undefined);
    else
      return undefined as (B extends true ? GeoError : undefined);
  }
  return position;
}

which works but gets us back to silent failures instead of false warnings... you could change if (withError) to if (!withError) and there'd be no error.

So it's kind of up to you whether you want overloads or conditional-generics. Neither one is perfect.


Now, it is possible to get some type safety from the implementation, but it's frankly ugly and bizarre. The compiler is pretty good at verifying assignability to generic indexed access types as long as you are actually indexing into an object with a generic key. Your withError input isn't like a key though, it's a boolean (or undefined). But we can use template literals to turn true, false, or undefined, into the string literals "true", "false", or "undefined", and use those as keys. And the compiler can use template literal types to follow this logic.

Here it is:

function getPosition<T extends [withError?: false] | [withError: true]>(
  ...args: T
) {
  let position: GeoLocation = null!;
  let positionError: GeoError = null!;
  if (!position) {
    const withError: T[0] = args[0];
    return {
      true: positionError,
      false: undefined,
      undefined: undefined,
    }[`${withError}` as const];
  }
  return position;
}

I'm making the function generic in the rest tuple type T of the args rest parameter, so that the compiler will understand that even leaving out the withError input can be used to determine the generic type argument. The type of withError is T[0].

And then we index into an object with the serialized version of that as keys. This produces the following crazy call signature type:

/* function getPosition<
  T extends [withError?: false | undefined] | [withError: true]
>(...args: T): GeoLocation | {
  true: GeoError;
  false: undefined;
  undefined: undefined;
}[`${T[0]}`] */

But it works:

const y = getPosition(true); // GeoLocation | GeoError;
const n = getPosition(false); // GeoLocation | undefined;
const n2 = getPosition(); // GeoLocation | undefined;
const yn = getPosition(Math.random() < 0.5) // GeoLocation | GeoError | undefined

And this time, if you change the implementation, the output type will change accordingly:

    return {
      true: undefined,
      false: positionError,
      undefined: positionError,
    }[`${withError}` as const];

const y = getPosition(true); // GeoLocation | undefined;
const n = getPosition(false); // GeoLocation | GeoError;

So the compiler really is verifying types for you. Hooray!

But at what cost? I wouldn't want to see code like this in any production environment; overloads or even generic conditionals with type assertions are much more reasonable, even though they aren't as type safe in the implementation. Type safety isn't always more important than idiomatic coding style.

Playground link to code

  • Related