I can easily use a constant string value to narrow down a union type:
type Payload1 = { /* ... arbitrary type ... */ };
type Payload2 = { /* ... arbitrary type ... */ };
type T1 = { type: 'type1', payload: Payload1 }
type T2 = { type: 'type2', payload: Payload2 }
type T = T1 | T2;
const fn = (value: T) => {
if (value.type === 'type1') {
value; // Typescript knows `value is T1`
}
if (value.type === 'type2') {
value; // Typescript knows `value is T2`
}
};
Here there are only two cases:
value.typeis the constant"type1"value.typeis the constant"type2"
But what if I expand T, allowing payload to be either a single item or an array? Now there are 4 possibilities:
value.typeis"type1"andvalue.payloadis not anarrayvalue.typeis"type1"andvalue.payloadis anarrayvalue.typeis"type2"andvalue.payloadis not anarrayvalue.typeis"type2"andvalue.payloadis anarray
Here is an example:
type Payload1 = {};
type Payload2 = {};
type T1Single = { type: 'type1', payload: Payload1 }
type T1Batch = { type: 'type1', payload: Payload1[] };
type T2Single = { type: 'type2', payload: Payload2 }
type T2Batch = { type: 'type2', payload: Payload2[] };
// Here's T, now with 4 types instead of 2:
type T = T1Single | T1Batch | T2Single | T2Batch;
const fn = (value: T) => {
if (value.type === 'type1' && !Array.isArray(value.payload)) {
value; // Typescript says `value is T1Single | T1Batch`?!
// How does `T1Batch` remain in the union if `value.payload` isn't an array??
}
if (value.type === 'type1' && Array.isArray(value.payload)) {
value; // Typescript says `value is T1Single | T1Batch`?!
// How does `T1Single` remain in the union if `value.payload` is an array??
}
if (value.type === 'type2' && !Array.isArray(value.payload)) {
value; // Typescript says `value is T2Single | T2Batch`?!
// How does `T2Batch` remain in the union if `value.payload` isn't an array??
}
if (value.type === 'type2' && Array.isArray(value.payload)) {
value; // Typescript says `value is T2Single | T2Batch`?!
// How does `T2Single` remain in the union if `value.payload` is an array??
}
};
Why is typescript only partially narrowing down the type, and how can I achieve fully narrowed values for the 4 cases?
EDIT: Looks like multiple conditions in the if is irrelevant; typescript struggles to narrow based on Array.isArray alone:
type Payload = {};
type Single = { payload: Payload }
type Batch = { payload: Payload[] };
const fn = (value: Single | Batch) => {
if (!Array.isArray(value.payload)) {
value; // Typescript says `value is Single | Batch`?!
}
if (Array.isArray(value.payload)) {
value; // Typescript says `value is Single | Batch`?!
}
};
You are trying to treat
Tas a discriminated union, but thepayloadproperty is not recognized as a discriminant. For a property to be seen as a valid discriminant, it must contain unit/literal types. Yourtypeproperty is valid because"type1"and"type2"are string literal types. But arrays and yourPayloadtypes are object types, not literal types. So you can't checkvalue.payloadand have it narrow the apparent type ofvalueitself.Note that
Array.isArray(value.payload)does act as a type guard on thevalue.payloadproperty, but because the property is not a discriminant, this narrowing does not propagate up tovalueitself. There is an open feature request at microsoft/TypeScript#42384 to allow property type guards to propagate up to containing objects. It's not part of the language yet, though, and previous requests for it were declined as it was considered too expensive to synthesize new types for every type guard check on a nested property.For now, if you want to get behavior like this you could write a custom type guard function that narrows a value based on whether its
payloadproperty is an array. Like this:Then instead of writing
Array.isArray(value.payload)inline, you callhasArrayPayload(value):Playground link to code