Ignat Insarov
Ignat Insarov

Reputation: 4832

Why does narrowing become forgotten inside a function expression?

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!

example

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.

solution

It is not very hard to modify the code to get a type checking success. But before I show the solution, observe that:

— 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!

question

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

Answers (2)

jcalz
jcalz

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.

Playground link to code

Upvotes: 2

Alexander Nenashev
Alexander Nenashev

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):

Playground

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.

Playground

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

Related Questions