Why is a type narrowed when defined as a literal but not narrowed when returned from a function?

72 views Asked by At

I'm trying to understand why type-narrowing isn't happening here.

Example where name is narrowed:

function getPath(name: string | null): "continue" | "halt" {
  if (name) {
    return "continue";
  }

  return "halt";
}

function doSomethingWithName(name: string): number {
  return name.length;
}

const name: string | null = "john";

const path = getPath(name);

if (path === "continue") {
  // All good
  doSomethingWithName(name);
}

Example where name is not narrowed:

function getPath(name: string | null): "continue" | "halt" {
  if (name) {
    return "continue";
  }

  return "halt";
}

function doSomethingWithName(name: string): number {
  return name.length;
}

function getName(): string | null {
  return "john";
}

const name = getName();

const path = getPath(name);

if (path === "continue") {
  // TypeError: Argument of type 'string | null' is not assignable to parameter of type 'string'. Type 'null' is not assignable to type 'string'.
  doSomethingWithName(name);
}

I'm clearly missing something about how type-narrowing is supposed to work. Why does it matter if name is set as a literal or as the return value of the function, if the check that should narrow the type happens after the value of the variable has been set?

Edit: Thank you for the replies. Yes, I was mistaken in thinking that the explicit type would force Typescript to consider even the literal a string | null. A followup question: why does getPath not narrow down the type of name? If it returns 'continue' name must be a string, right?

2

There are 2 answers

1
Matthieu Riegler On

The compiler is able to infer that :

const myName: string | null = "john";

is actually myName: string.

This is statical analysis and optimisation. Since myName is not reassigned it cannot be something else than a string.

1
Lesiak On

In your code:

const name: string | null = "john";

TS infers name to be string

The compiler is using Control flow based type analysis

The narrowed type of a local variable or parameter at a given source code location is computed by starting with the initial type of the variable and then following each possible code path that leads to the given location, narrowing the type of the variable as appropriate based on type guards and assignments.

  • The initial type of local variable is undefined.
  • The initial type of a parameter is the declared type of the parameter.
  • The initial type of an outer local variable or a global variable is the declared type of that variable.
  • A type guard narrows the type of a variable in the code path that follows the type guard.
  • An assignment (including an initializer in a declaration) of a value of type S to a variable of type T changes the type of that variable to T narrowed by S in the code path that follows the assignment.
  • When multiple code paths lead to a particular location, the narrowed type of a given variable at that location is the union type of the narrowed types of the variable in those code paths.

The type T narrowed by S is computed as follows:

  • If T is not a union type, the result is T.
  • If T is a union type, the result is the union of each constituent type in T to which S is assignable.

Note that the step "An assignment (including an initializer in a declaration) of a value of type S to a variable of type T changes the type of that variable to T narrowed by S in the code path that follows the assignment." is the source of the difference you observed:

  • if you assign a value known to be a string, the variable can be narrowed down to string
  • if you assign a value known to be a string | undefined, the variable is not narrowed down and stays as string | undefined (its declared type)

Update for follow-up question

The compiler does cannot track the results of control flow analysis across function boundaries, and it assumes that called functions have no effect on the apparent type of any variables. See microsoft/TypeScript#9998