12

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.

Jake Boone
  • 1,090
  • 1
  • 11
  • 19
  • 1
    The problem is that `T extends RGT` does not mean `T` is either `RG` or `RGIC`; it means it can be some *subtype* of that, like [this](https://tsplay.dev/NlpYeN). You can't honestly claim to return a value of the same type as that passed in. Maybe you should forget about generics and use overloads like [this](https://tsplay.dev/wRJdXw)? If that meets your needs I can maybe write up an answer. If not, please elaborate about the unsatisfied use cases. – jcalz Oct 31 '21 at 01:46
  • Yes, I think the overloaded function method would work. Want to write up an answer so I can mark it as accepted? – Jake Boone Oct 31 '21 at 02:26

1 Answers1

37

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

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Great answer! Thanks! I think I finally understand. I wasn't able to get the function overload method to work in my own code, but the conditional type method did work. – Jake Boone Oct 31 '21 at 04:48
  • 2
    This was the answer that finally helped me to understand this error which I've seen so many times but never understood. I just never realized that you could extend an interface while making existing key types _more_ specific (because I've never wished to do so). Thanks so much for your detailed and patient explanation! – Nick Farina Mar 06 '22 at 00:27
  • @Archsx I moved that part below the implementation. Does that improve it? – jcalz Aug 29 '22 at 12:42
  • @jcalz Any idea how to use the conditional type for when to use user-defined type guards? The compiler still complains about T could be instantiated with a different subtypes – Trung Tín Trần Sep 11 '22 at 17:31