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"
and value.payload
is not an array
value.type
is "type1"
and value.payload
is an array
value.type
is "type2"
and value.payload
is not an array
value.type
is "type2"
and value.payload
is an array
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 the payload
property is not recognized as a discriminant. For a property to be seen as a valid discriminant, it must contain unit/literal types. Your type
property is valid because "type1"
and "type2"
are string literal types. But arrays and your Payload
types are object types, not literal types. So you can't check value.payload
and have it narrow the apparent type of value
itself.
Note that Array.isArray(value.payload)
does act as a type guard on the value.payload
property, but because the property is not a discriminant, this narrowing does not propagate up to value
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:
function hasArrayPayload<T extends { payload: any }>(
value: T): value is Extract<T, { payload: any[] }> {
return Array.isArray(value.payload)
}
Then instead of writing Array.isArray(value.payload)
inline, you call hasArrayPayload(value)
:
const fn = (value: T) => {
if (value.type === 'type1' && !hasArrayPayload(value)) {
value; // (parameter) value: T1Single
}
if (value.type === 'type1' && hasArrayPayload(value)) {
value; // (parameter) value: T1Batch
}
if (value.type === 'type2' && !hasArrayPayload(value)) {
value; // (parameter) value: T2Single
}
if (value.type === 'type2' && hasArrayPayload(value)) {
value; // (parameter) value: T2Batch
}
};