[FIXED] Type "is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'RGT'

Issue

I’m struggling with this error message in TypeScript. I’ve looked at the following SO answers and I simply don’t understand the solutions.

Here is a stripped down version of my code with the error:

interface R {
    field: string;
    operator: string;
    value: any;
}
interface RG {
    combinator: 'and'|'or';
    rules: (R|RG)[];
}
interface RGIC {
    rules: (RGIC|R|string)[];
}
type RGT = RG | RGIC;

const f = <T extends RGT>(r: T): T => {
    if ('combinator' in r) {
        return { combinator: 'and', rules: [] }; // <-- TS error here
    }
    return { rules: [] }; // <-- TS error here
}

What I want to express is that if r is of type RG, then function f will return an object that is also type RG. If r is of type RGIC, then function f will return an object that is also type RGIC. But this error is confounding me.

TS Playground link.

Solution

The problem you’re running into is that generic constraints of the form T extends RGT does not mean "T must be exactly either RG or RGIC". All it implies is that T is compatible with RGT, meaning that it is a subtype of or assignable to RGT. There are subtypes of RGT which are more specific than either RG or RGIC, such as:

interface Whaa {
    combinator: 'or',
    rules: R[],
    brainCellCount: number;
}

const whaa: Whaa = {
    combinator: 'or',
    rules: [],
    brainCellCount: 86e9
}

The Whaa interface is assignable to RG; every Whaa is a valid RG. But a Whaa has a combinator which must be "or", and it also has an additional brainCellCount property of type number.

If your function f has a call signature like this:

declare const f: <T extends RGT>(r: T) => T;

you’re saying that the return type of f will be exactly the same as the type of the passed-in r. And therefore the following call will produce a result of type Whaa also:

const hmm = f(whaa);
// const hmm: Whaa

And if so, then this will be fine:

hmm.brainCellCount.toFixed(2); // no compiler error

But it’s not fine with your implementation of f:

const f = <T extends RGT>(r: T): T => {
    if ('combinator' in r) {
        return { combinator: 'and', rules: [] }; // error
    }
    return { rules: [] }; // error
}

hmm.brainCellCount.toFixed(2); // no compiler error
// BUT AT RUNTIME: 💥 brainCellCount is undefined !!

Now the error should hopefully makes sense. You’re returning a value like {combinator: "and", rules: []} and claiming that it’s the same type T as that of r. But the compiler is saying that such a value, while assignable to RGT, might not be assignable to T.


There are different ways to handle this. Since you really just want to say that if the input is an RG then so is the output, and if the input is an RGIC then so is the output, then the easiest way is probably to make f an overloaded function with two call signatures:

// call signatures
function f(r: RG): RG;
function f(r: RGIC): RGIC;

// implementation
function f(r: RGT): RGT {
    if ('combinator' in r) {
        return { combinator: 'and', rules: [] };
    }
    return { rules: [] };
}

This compiles with no error. Although you should be careful: it’s not really guaranteed to be type safe; the compiler won’t catch errors like if ('cobminator' in r) because overload implementations are checked loosely. As long as each return statement applies to some call signature the compiler will be happy, even if you get the wrong one. So just double and triple check that your implementation works for each call signature. It does, so that’s good.

Let’s see how it works when we call it:

const hmm = f(whaa);
// const hmm: RG
hmm.brainCellCount //<-- now this is a compiler error

Looks good. Now the compiler accepts that whaa is an RG and the output type is also an RG. If you try to access the brainCellCount property of hmm, the compiler now complains that there is no such property on RG.


Another similar way to handle this is to continue to use a generic function, but have the return type be a conditional type like this:

const f = <T extends RGT>(r: T): T extends RG ? RG : RGIC => {
    if ('combinator' in r) {
        return { combinator: 'and', rules: [] } as any;
    }
    return { rules: [] } as any;
}

That return type T extends RG ? RG : RGIC captures your intent of widening the return type to RG if the input is some subtype of RG, or otherwise widening to RGIC. Note that in both return statements I had to use a type assertion to any (I could have done as T extends RG ? RG : RGIC instead) to make it compile. That’s because the compiler is really not able to understand when some specific value is assignable to a conditional type depending on an unspecified generic type parameter. There’s an open feature request at microsoft/TypeScript#33912 asking for something better there, but I don’t know when or if it will improve. For now, type assertions or overloads are the ways to go.

Anyway, you can see that it behaves the same:

const hmm = f(whaa);
// const hmm: RG
hmm.brainCellCount // error

when you call f(whaa) the compiler infers that T is Whaa, and then evaluates T extends RG ? RG: RGIC to be RG. And so hmm is of type RG as desired, which is not known to have a brainCellCount property. And you get the compiler error where expected.


Playground link to code

Answered By – jcalz

Answer Checked By – Terry (Easybugfix Volunteer)

Leave a Reply

(*) Required, Your email will not be published