Brian M. Hunt
Brian M. Hunt

Reputation: 83818

Force Typescript generic parameters (a: T, B: T) to be of the same type

Given:

function sorter<T extends string | number> () {
  const icf = new Intl.Collator().compare
  return (a: T, b: T) => typeof a === 'number' ? a - b : icf(a, b)
}

We get a Typescript type error:

typescript error

We know that if a is a number then b will also be a number, and similarly if a is a string then b will also be a string. This information is not known by the function calling sorter i.e. only at the time the function being returned is called. By design we never expect to get heterogeneous items.

What options are there to assert these types correctly?

Typescript Playground

Upvotes: 2

Views: 757

Answers (2)

Mack
Mack

Reputation: 771

Your issue is that the restriction on the generic type T extends string | number does not enforce a and b having the same primitive type in (a: T, b: T) => .... One can be a string and the other a number, if T is itself the union type string | number.

For example:

// this compiles fine, even though the parameters have different types
const a: number | string = 2;
const b: number | string = '2';
sorter("en-us")(a, b);

Further, you've made the sorter function generic, meaning that the type of the comparator function's parameters is chosen at the point in time where sorter is called. In reality, it should be chosen by the caller of the function returned by sorter(). If we make this anonymous function generic by returning <T,>(a: T, b: T) => typeof ... we run into the same issue: this permits the caller to choose string | number for the type T and pass in a string and a number for comparison.

It is a bit ugly, but we can get the desired behaviour by instead returning a function that has declared overloads only for (string, string) => number and (number, number) => number. We need a single type assertion, but it is due only to a limitation of type inference in function overloads:

export function sorter(locale: string) {
  const icf = new Intl.Collator(locale).compare

  function comp(a: number, b: number): number;
  function comp(a: string, b: string): number;
  function comp(a: number | string, b: number | string) : number {
    if (typeof a === "number") {
      // assertion needed here: TS can't infer one param's type from another's via
      // their relationship in overloads
      // (but it is safe: this fn can only be called via a declared overload)
      return a - (b as number);
    } else {
      return icf(a, b as string);
    }
  }

  return comp;
}

Upvotes: 1

ferrouskid
ferrouskid

Reputation: 661

You could do something like this:

type T = number | string;

function numSorter(a: number, b: number){
  return a - b;
}

function stringSorter(a: string, b: string, locale: string) {
  const icf = new Intl.Collator(locale).compare;
  return icf(a,b)
}

function picker(a: T, b: T, locale: string) {
  if (typeof a === 'number' && typeof b === 'number') {
    return numSorter(a,b);
  } else if (typeof a === 'string' && typeof b === 'string') {
    return stringSorter(a,b, locale);
  } else {
    throw Error("incompatible types");
  }
}

function sorter(locale: string) {
  return (a:T, b:T) => picker(a,b,locale);
}

const mySorter = sorter("US");

console.log(mySorter(1,2)); //both numbers, so ok
console.log(mySorter("world","hello")); // both strings, so ok
console.log(mySorter(1,"hello")); // incompatible types

Which gives an output like this:

enter image description here

I could be mistaken, but I think that this kind of approach defeats the purpose of static type checking. The generic reference still has to have a concrete implementation.

In this case, the operations you are performing are different for the different types number and string, so theres no overlap. The generic approach might be useful for objects that do have some overlap in functionality, like a common method they both have, like if you wanted a helper method for finding length of an array (as shown in the docs):

function loggingIdentity<Type>(arg: Array<Type>): Array<Type> {
  console.log(arg.length); // Array has a .length, so no more error
  return arg;
}

This generic function will work for any array of any type, because any array has the length property.

Overall, I don't think its useful for this case, I think it might be better to have concrete implementations from the start, and calling the correct sorting function when needed. Generics are a bit of a headache to be honest, especially in TypeScript.

Upvotes: 1

Related Questions