Reputation: 4832
I wish to define a function in a different way depending on whether a field in the provided value is null
. I expect that the provided value's type will be appropriately narrowed inside the function expression. However, it seems TypeScript forgets about the narrowing!
I am now going to offer an example. This is a simplified version of my actual code, so it may look silly. My actual code is way more complicated — however, I believe this simplified version will be enough to illustrate the problem.
What we are going to assume here is an object type core
with a nullable field contract_details
, and we are then going to define a function example
that returns different expressions depending on whether this field is null
in its input object.
type core = {
contract_details: null | {
contract_address: string;
};
};
function example(core: core): (input: string) => void {
return core.contract_details === null
? (contract_address) =>
console.log({
contract_details: {
contract_address: contract_address,
},
})
: (contract_address) =>
console.log({
contract_details: {
contract_address: core.contract_details.contract_address,
},
});
}
Add the following tsconfig.json
:
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"esModuleInterop": true,
"jsx": "react-jsx",
"module": "esnext",
"moduleResolution": "node",
"target": "esnext",
"strict": true
},
"include": ["."]
}
— And observe that running tsc
of version 5.3.3 produces this error:
main.ts:18:31 - error TS18047: 'core.contract_details' is possibly 'null'.
18 contract_address: core.contract_details.contract_address,
~~~~~~~~~~~~~~~~~~~~~
Found 1 error in main.ts:18
This seems to be a nonsensical error. In the expression after the colon, we have narrowed the type of core.contract_details
to exclude null
, so it is not possible for it to be null.
It is not very hard to modify the code to get a type checking success. But before I show the solution, observe that:
if
statementfunction
functions— does not solve the problem.
The solution is to add what I call a «let-lambda» — an anonymous function that is immediately applied.
function example(core: core): (input: string) => void {
return core.contract_details === null
? (contract_address) =>
console.log({
contract_details: {
contract_address: contract_address,
},
})
: (
(contract_details) => (contract_address) =>
console.log({
contract_details: {
contract_address: contract_details.contract_address,
},
})
)(core.contract_details);
}
Incidentally, this proves that the value core.contract_details
was seen as having been narrowed to exclude null
in the expression after the colon — but only outside the function expression!
What is going on here? What is the reason for narrowing to become forgotten like this? Why do I need to write a «let-lambda» here?
Upvotes: 1
Views: 76
Reputation: 329858
Narrowing in TypeScript cannot cross function boundaries, except in very specific circumstances where the type checker can be sure that it is appropriate to do so. After all, functions can be called multiple times, or never, by different callers, and the narrowing that happened might possibly no longer apply by the time callers invoke the function.
In your example, it is actually inappropriate to do such narrowing, since nothing prevents someone from doing this:
const core: Core = { contract_details: { contract_address: "abc" } };
const result = example(core);
core.contract_details = null;
result("def"); // 💥 ERROR! core.contract_details is null
Here the narrowing that produced result()
is no longer valid when result()
is called, and you get a runtime error. Oops.
On the other hand, your let-lambda approach works because the value being passed into the returned function is definitely the narrowed value, as it occurs inside the same scope. So if you use that implementation, you don't have a runtime error, because modification of the input value doesn't matter anymore:
const core: Core = { contract_details: { contract_address: "abc" } };
const result = example(core);
core.contract_details = null;
result("def"); // okay, logs the previous value of contact_address
Of course there are many situations similar to yours where TypeScript reports a similar error, but the failure simply cannot happen by construction, where the function cannot be called in an unsafe way or at an unexpected time:
function foo(x: { a: string | number }) {
if (typeof x.a === "string") {
x.a.toUpperCase(); // okay
console.log(["?", ".", "!"].map(
y => x.a.toUpperCase() + y)) // error!
} else {
0 + x.a; // okay
console.log([1, 2, 3].map(
y => y + x.a)); // error
}
}
foo({ a: "abc" })
foo({ a: 123 })
Here we know that the callback to map()
is going to be run immediately, so even if x.a
is modified somewhere, it will still be safely narrowed when the callback runs. But TypeScript does not know this; it doesn't know how map()
is implemented, It can't be sure that map()
doesn't keep the function around somewhere and call it much later. So it complains.
There is some hope that soon TypeScript will support annotations to say that a library method like map()
immediately calls its callback, as implemented in microsoft/TypeScript#58729, and then the above code will start working without error.
But that's just one example, and there are many cases where TypeScript cannot conclude that the code is safe. This is a longstanding general limitation of TypeScript, which will probably remain in some form forever, even if pieces of it are improved; see microsoft/TypeScript#9998 for a description and a lengthy discussion about this.
Upvotes: 2
Reputation: 23607
Seems TS doesn't narrow by union object property.
A solution would be if you narrow by the property written/destructured into a variable (similar to your solution):
type core = {
contract_details: null | {
contract_address: string;
};
};
function example({contract_details} :core): (input: string) => void {
return contract_details === null
? (contract_address) =>
console.log({
contract_details: {
contract_address: contract_address,
},
})
: (contract_address) =>
console.log({
contract_details: {
contract_address: contract_details.contract_address,
},
});
}
If you split the type you could apply discriminated union narrowing. I like this approach more since a narrowed core
is available fully. If you have more fields you could move the common fields into a separate type and intersect with it.
type core = {
contract_details: {
contract_address: string;
};
} | {
contract_details: null
};
function example(core: core): (input: string) => void {
return core.contract_details === null
? (contract_address) =>
console.log({
contract_details: {
contract_address: contract_address,
},
})
: (contract_address) =>
console.log({
contract_details: {
contract_address: core.contract_details.contract_address,
},
});
}
Upvotes: 1