3

I'm having trouble understanding why TypeScript is inferring a certain type for an array element when the type is a union type and the types 'overlap'. I've reduced it to this minimum repro:

interface Base {
    id: string;
}

interface Child {
    id: string;
    parentId: string;
}

interface Obj {
    nodes1: Base[] | Child[];
    nodes2: (Base | Child)[];
}

const obj: Obj = {
    nodes1: [],
    nodes2: []
};

const node1 = obj.nodes1[0]; // typed as Base
const node2 = obj.nodes2[0]; // typed as Base | Child

If I change Base so it isn't just a subset of Child properties like this:

interface Base {
    baseId: string;
}

Now, both node1 and node2 are inferred as Base | Child. This is what I'd expected the first time. In my real code, the array can either be all of type Base or all of type Child so the typing feels better as Base[] | Child[] but I've had to go with (Base | Child)[] for now. I could do a larger refactor to introduce generics but it's not a simple change.

Why is the type inferred as Base only and not Base | Child?

1 Answer 1

3

See microsoft/TypeScript#43667 for a canonical answer. This is a design limitation of TypeScript.

As you might be aware: in TypeScript's structural type system, Child is a subtype of Base even though it is not explicitly declared as such. So every value of type Child is also a value of type Base (although not vice-versa). That means Child | Base is equivalent to Base... although the compiler is not always aggressive about reducing the former to the latter. (Compare this to the behavior with something like "foo" | string, which is always immediately reduced to string by the compiler.)

Subtype reduction is often desirable, but there are some places where Child | Base's behavior is observably different from Base's, such as excess property checks, IntelliSense hinting, or the sort of unsound type guarding that happens with the in operator. You haven't shown why it matters to you that you are getting a Base as opposed to a Child | Base, but presumably it's one of these observable differences or something like it.


My advice here is first to think carefully about whether or not you really need this distinction. If so, then you might consider preventing Base from being a subtype of Child, possibly by adding an optional property to it:

interface Base {
    id: string;
    __baseMarker?: never; // <-- added property
}

So now we're saying that a Base has a string-valued id and either no __baseMarker property, or one with a value of type never (which is impossible). So it's basically "there is no __baseMarker property" (well, it could be undefined). This isn't much different from your original definition, but now Child extends Base is false and Child | Base is not equivalent to Base:

const node1 = obj.nodes1[0]; // typed as Base | Child

Playground link to code

Sign up to request clarification or add additional context in comments.

1 Comment

Thank you, great answer. I did a search on GH (and SO) but hadn't found the answer. The code branches later and uses something on Child (if it is a Child). Because TS thinks it will never be Child, it infers the type as never after the check and so results in an error.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.