Skip to content

Type narrowing with assertions containing truthy literals #41503

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
mlrawlings opened this issue Nov 11, 2020 · 6 comments
Open

Type narrowing with assertions containing truthy literals #41503

mlrawlings opened this issue Nov 11, 2020 · 6 comments
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript

Comments

@mlrawlings
Copy link
Contributor

mlrawlings commented Nov 11, 2020

TypeScript Version: 4.2.0-dev.20201109

Search Terms:

type narrowing truthy
type narrowing assertion
conditional throw

Code

function test(foo?: number) {
  if (!foo) throw new Error();

  return foo.toFixed();
}

function testWithLiteralTrue(foo?: number) {
  if (!foo && true) throw new Error();

  return foo.toFixed();
}

function testWithTruthyLiteral(foo?: number) {
  if (!foo && "str") throw new Error();

  return foo.toFixed(); // Object is possibly 'undefined'.
}

Expected behavior:

In all three functions typescript would know foo is a number after the assertion.

Actual behavior:

In the last example, testWithTruthyLiteral, typescript complains foo may be undefined. This is the same behavior for all known truthy values except for literal true.

Playground Link:
https://www.typescriptlang.org/play?ts=4.2.0-dev.20201109#code/GYVwdgxgLglg9mABFApgZygRgBTDnAfgC5EwQBbAIxQCcBKRAbwChFEZhFsBCPOBqAAsacAO6kU4gKI0RNbHQDczVohoooIGkj4A6KHABiMAB4oAJguUBfFaEiwEydFABMufMVIVq9Jqo4uXnxEADJQ5BoQFAFhMQlpWTh5JRU2dU1tRD0DYzNLVNtme2h4JFQMAGYPQhIyKloGFjZAnj4wiIAiDBpO2JFxMElEGTkrNLUNLR18fSNTC3GiksdylwAWGq963yaAzjaQ8MRMfvihxLHU1Qzp7NnchYKbO3BSpwqoAFYtup9G-wtA7BOAdJjWM6DYajZLjG5TLI5eb5JbMIA

Related Issues:
#29323

It sounds like from #29323 (comment) that determining truthiness of expressions could lead to circular dependencies between compiler passes, but that literal true has been special cased. This should be able to be extended to truthy literals without the circular dependency concern.

And for context, like others that have brought up similar issues, this stems from wanting a truthy token that can be used for a minification hook without affecting the types or code execution.

@RyanCavanaugh RyanCavanaugh added In Discussion Not yet reached consensus Suggestion An idea for TypeScript labels Nov 13, 2020
@RyanCavanaugh
Copy link
Member

I'm still not quite understanding why you would write if (!foo && "str") { - can you expand on that scenario?

@mlrawlings
Copy link
Contributor Author

It's definitely kind of strange. The goal, like this comment, is to have assertions and other code that can be stripped out in a production build.

Here's a contrived example:

let list: number[] | undefined;

export function setup() {
  list = [];
}

export function append(value: number) {
  if ("DEBUG_TOKEN" && !list) {
    throw new Error("You must call `setup` before appending values");
  }
  list.push(value);
}

export function cleanup() {
  list = undefined;
}

Then, for a production build, you replace "DEBUG_TOKEN" with false using something like @rollup/plugin-replace so it gets eliminated with dead code removal:

export function append(value) {
  list.push(value);
}

I find myself using this pattern in many cases rather than the non-null assertion operator so that it's explicit why I expect the value to be non-null, and if for some reason it is null, you get a nice explanative error in development environments.

Besides non-null, we use this pattern for some assertion functions as well. One of them requires a recursive tree walk in a fairly hot codepath. It's nice that we can provide these errors during development, but not pay the cost of the check when running in production.

It would be really nice if we didn't have to sprinkle ! all over or do things like this to get the types correct:

if ("DEBUG_TOKEN" && !list) {
    throw new Error("You must call `setup` before appending values");
}
list = list!;

or

if ("DEBUG_TOKEN" && !someExpensiveAssertion(value)) {
  throw new Error("A helpful error");
}
value = value as TypeThatTheAssertionSaidItWas;

Especially because tools like terser don't remove this redundant assignment.

@RyanCavanaugh
Copy link
Member

I don't know why, but putting the literal first makes way more sense to me 😅

@houghtonap
Copy link

I just came across something similar, but not sure whether it's the same use case:

function a ( name ?: string )
{
    let theName = name ?? '' !== '' ? name : 'foo' ;
}

In this scenario I would have expected that typescript would have limited theName to type string, but it doesn't and theName has type string | undefined.

The name ?? '' !== '' should have clued the static type analyzer that name cannot be undefined, thus ? name can only be a string that contains something or is an empty string, otherwise the : 'foo' is a string, therefore the resulting expression can never be undefined.

I have simplified this scenario, but a temporary work around is:

let theName = name ?? '' !== '' ? name ?? '' : 'foo' ;

but I really shouldn't have to add the extra ?? ''.

@RyanCavanaugh
Copy link
Member

@houghtonap narrowing can't perform that kind of higher-order reasoning

It seems like the easier form of that is

let theName = name?.length ? name : 'foo';

Here, TS knows that only a not-undefined name can produce a not-undefined .length, and as a bonus there's the reader doesn't have to remember what JS's precedence rules are 😉

@RyanCavanaugh
Copy link
Member

Wait, this is just

let theName = name || "foo";

since '' is falsy

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

3 participants