Search code examples
typescripttypescript-generics

Argument requirement dependence on type with default value [Class generic]


I want to create generic class, which has compare function, such that takes as an arguments only the provided type (number by default):

class Foo<T = number> {
    
    isGreater: (v1: T, v2: T) => boolean   

    constructor()
    constructor(isGreater: (v1: T, v2: T) => boolean) 
    constructor(isGreater?: (v1: T, v2: T) => boolean) {
        this.isGreater = isGreater ?? function (v1: number, v2: number): boolean {return v1 > v2};
//            ^ It gives an error, because @number are not equalent @T
    }

If change number to T you can get sistuation like that:

const inst = new Foo<string>() 
// No error, but compare function.. 
// ..by default works only with @numbers

I want user to have 2 possible ways to create an instance:

  1. No type annotation - all defaluts, so you get T as number by default and default function to compare numbers
  2. Type annotation (T) - you have to give function which satisfeis (v1: T, v2: T): boolean, except your type is number, so default function is OK

I tried to use condition:

...(v1: T extends number ? number : T, v2 T extends number ? number : T): boolean...

It didn't work. I tried to find a solution, but all of them lead to create type of unions, which can not be used here because of class (you cant use type on class properly), or create overloads on constructor, but it was for variables - at this case is a type depence.


Solution

  • Yes, this is a pain point of TypeScript. It's essentially a missing feature that you can't easily make a generic function (or constructor) with a default argument that corresponds to a default type argument. There's an open feature request at microsoft/TypeScript#56315 (at least for functions), but for now it's not part of the language and you need to work around it.

    The workaround for functions is easier than for generic class declarations because the generic type parameter for classes can't be changed on a per-construct-signature basis. But we can mostly do it. Here's one way:

    For ease of discussion, let's define a Cmp<T> utility type for comparisons, and give a name to that default argument:

    type Cmp<T> = (v1: T, v2: T) => boolean;
    const g: Cmp<number> = function (v1, v2) { return v1 > v2 }
    

    And now the class constructor looks like:

    class Foo<T = number> {
        isGreater: Cmp<T>
        constructor(
            ...args: [T] extends [number] ? [isGreater?: Cmp<T>] : [isGreater: Cmp<T>]
        )
        constructor(isGreater: Cmp<T> = g as Cmp<T>) {
            this.isGreater = isGreater;
        }
    }
    

    So we only have a single overloaded construct signature, which uses a rest parameter to let you either make the isGreater parameter optional or required depending on whether T is assignable to number (via a non-distributive conditional type).

    Then, inside the implementation, we just assert that g, the default Cmp<number> is a Cmp<T>. The intent here is that the only way this could actually happen is if T is assignable to number, and therefore Cmp<number> is assignable to Cmp<T> (note the change of assignability direction due to contravariance of function inputs, see Difference between Variance, Covariance, Contravariance, Bivariance and Invariance in TypeScript for more information).

    Let's test it out:

    const inst = new Foo() // okay
    //    ^? const inst: Foo<number>
    const inst2 = new Foo((x: string, y: string) => x.localeCompare(y) > 0);
    //    ^? const inst: Foo<string>
    const inst3 = new Foo(g);
    //    ^? const inst: Foo<number>
    const inst4 = new Foo<string>(); // error!
    // ---------> ~~~~~~~~~~~~~~~~~
    // Expected 1 arguments, but got 0.
    

    Looks good. If you construct new Foo() with no argument you get a Foo<number>. If you pass in an argument of type Cmp<T> for some T, then you get a Foo<T>. And if you try to specify the argument T manually as anything not assignable to number, the compiler will require that you pass in an argument for isGreater as well.

    So that's fairly type safe from the construct caller's point of view. From the class implementer's point of view, we've had to play some type juggling games and done assertions, so care needs to be taken. But until and unless microsoft/TypeScript#56315 is implemented, this is probably the best we can do.

    Playground link to code