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.type
is the constant"type1"
value.type
is 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.type
is"type1"
andvalue.payload
is not anarray
value.type
is"type1"
andvalue.payload
is anarray
value.type
is"type2"
andvalue.payload
is not anarray
value.type
is"type2"
andvalue.payload
is 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
T
as a discriminated union, but thepayload
property is not recognized as a discriminant. For a property to be seen as a valid discriminant, it must contain unit/literal types. Yourtype
property is valid because"type1"
and"type2"
are string literal types. But arrays and yourPayload
types are object types, not literal types. So you can't checkvalue.payload
and have it narrow the apparent type ofvalue
itself.Note that
Array.isArray(value.payload)
does act as a type guard on thevalue.payload
property, but because the property is not a discriminant, this narrowing does not propagate up tovalue
itself. 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
payload
property is an array. Like this:Then instead of writing
Array.isArray(value.payload)
inline, you callhasArrayPayload(value)
:Playground link to code