Skip to content
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

Allow ... | undefined in comparisons (<, >, etc) #61141

Open
6 tasks done
ericbf opened this issue Feb 7, 2025 · 18 comments
Open
6 tasks done

Allow ... | undefined in comparisons (<, >, etc) #61141

ericbf opened this issue Feb 7, 2025 · 18 comments
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript

Comments

@ericbf
Copy link
Contributor

ericbf commented Feb 7, 2025

🔍 Search Terms

comparison, strictNullChecks, undefined, greater than, less than, nullable, optional

✅ Viability Checklist

⭐ Suggestion

Please allow values that are undefined-able, like number | undefined, to be used in direct comparisons, like <, >, etc.

I know the more general request has been made for allowing "nullable" numbers (numbers | null and/or number | undefined) to be used in comparisons, see #17801, but, in my opinion, that is too broad. This request is specifically for allowing number | undefined in comparisons, not number | null. This would make comparisons of values that are optional, but not nullable, much simpler.

📃 Motivating Example

In JavaScript, undefined coerces into NaN when coerced into a number, like when used in a comparison (contrast that with null, which coerces to 0 and would break code—again null is not included here).

Since undefined coerces to NaN, this would evaluate to false in all comparisons (even with another undefined value), which would match the expected behavior (in all except maybe undefined <= undefined and undefined >= undefined, which evaluate to false). This wouldn’t be a breaking change because any code that already compiles would still compile. No functionality would be different.

Given the one minor caveat of greater-than-or-equal and less-than-or-equal, which I estimate to be a minor caveat which would only come into play if comparing two optional numbers and expecting to take them as equal if they are both undefined, I think this feature would help to simplify code a lot.

declare const student: {
    score?: number
}

if (student.score > 95) {
    console.log("Good job!")
}

Obviously that is more concise than the required, and redundant null check.

declare const student: {
    score?: number
}

if (student.score !== undefined && student.score > 95) {
    console.log("Good job!")
}

This is even more pronounced when this is a calculated value from a long running function, maybe in a ternary context where you can’t easily save it to a variable ahead of time. You end up having to use a IIFE:

declare function heavyFunction(): number | undefined

function maybeDoSomething(evaluateTheThing: boolean) {
	return evaluateTheThing
		? heavyFunction() > 10
			? "yes, thing was greater than 10"
			: "thing was not greater than 10"
		: "thing not evaluated"
}

In the above trivial example, if the heavy function only gets evaluated conditionally, it’s not so simple to save it to a variable to perform the undefined check (obviously in this simple example, you could evaluate the initial ternary to a variable, but it’s just an example).

💻 Use Cases

  1. What do you want to use this for?

This would greatly simplify comparisons of optional numbers.

  1. What shortcomings exist with current approaches?

You can’t always save it to a variable to avoid evaluating functions twice, and it’s not so simple to check undefined-ness inline sometimes.

  1. What workarounds are you using in the meantime?

Having to do some ugly constructs to conditionally evaluate heavy functions and save to variables, then use that optional variable instead of simply evaluating the function in the comparison. It’s way more verbose than just allowing undefined in comparisons.

@ericbf ericbf changed the title Allow undefined (_not_ null) in comparisons (<, >, etc) Allow undefined (but _not_ null) in comparisons (<, >, etc) Feb 7, 2025
@ericbf ericbf changed the title Allow undefined (but _not_ null) in comparisons (<, >, etc) Allow undefined (not null) in comparisons (<, >, etc) Feb 7, 2025
@ericbf ericbf changed the title Allow undefined (not null) in comparisons (<, >, etc) Allow number | undefined (not number | null) in comparisons (<, >, etc) Feb 7, 2025
@ericbf ericbf changed the title Allow number | undefined (not number | null) in comparisons (<, >, etc) Allow number | undefined in comparisons (<, >, etc) Feb 7, 2025
@ericbf ericbf changed the title Allow number | undefined in comparisons (<, >, etc) Allow ... | undefined in comparisons (<, >, etc) Feb 7, 2025
@MartinJohns
Copy link
Contributor

MartinJohns commented Feb 7, 2025

Duplicate of #45543.

What workarounds are you using in the meantime?
Having to do some ugly constructs to conditionally evaluate heavy functions and save to variables, then use that optional variable instead of simply evaluating the function in the comparison. It’s way more verbose than just allowing undefined in comparisons.

You can just use the non-null assertion operator:

declare const value: number | undefined;
if (value! > 123) {}

@RyanCavanaugh RyanCavanaugh added the Duplicate An existing issue was already created label Feb 7, 2025
@RyanCavanaugh
Copy link
Member

I really question how good an idea this is, even in principle. Are you not worried about someone refactoring this code

if (student.score > 95) {
    console.log("Good job!")
}

into this

if (student.score <= 95) {
    console.log("At least you tried")
} else {
    console.log("Good job!")
}

without realizing they've changed the behavior?

@ericbf
Copy link
Contributor Author

ericbf commented Feb 7, 2025

Not particularly, as the type would still specify that it could be undefined at the type-checking level.

Future developers refactoring without paying attention to types is always a possibility, and quite unavoidable since people will do what people do.

The question is, does a particular limitation help more than it hinders (or makes inconvenient).

In the case of null, it would be very unexpected if that were allowed without error, since it coerces to 0 and will definitely lead to unexpected behavior.

But in this case, with undefined, it’s as good as NaN in comparisons. That’s to say, the same logic would apply if the value of score could be NaN. At least with undefined, any editor with type hints would show that score was number | undefined. I just am not seeing that it’s a huge benefit to require checking whether it's undefined first when the logical comparisons (except maybe in less-than-or-equals or greater-than-or-equals cases with both sides undefined, like I mentioned before) pan out.

Logically speaking, is 0 less than undefined? No. Is 0 greater than undefined? No. Is undefined less than or equal to 0? No. It all evaluates to false when one side is undefined, which is what you’d expect.

@RyanCavanaugh
Copy link
Member

In the case of null, it would be very unexpected if that were allowed without error, since it coerces to 0 and will definitely lead to unexpected behavior

People have made exactly this case in favor of allowing implicit coercion from undefined to 0 in bitwise ops #22230

"No implicit coercions, except for the ones I personally like" is just a very difficult line to draw

@ericbf
Copy link
Contributor Author

ericbf commented Feb 7, 2025

Duplicate of #45543.

What workarounds are you using in the meantime?
Having to do some ugly constructs to conditionally evaluate heavy functions and save to variables, then use that optional variable instead of simply evaluating the function in the comparison. It’s way more verbose than just allowing undefined in comparisons.

You can just use the non-null assertion operator:

declare const value: number | undefined;
if (value! > 123) {}

Definitely not a duplicate—that one includes null, this one explicitly does not. Not to mention that that one uses an incorrect example (null <= 100 evaluates to true, not false like in their example).

@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature and removed Duplicate An existing issue was already created labels Feb 7, 2025
@ericbf
Copy link
Contributor Author

ericbf commented Feb 7, 2025

In the case of null, it would be very unexpected if that were allowed without error, since it coerces to 0 and will definitely lead to unexpected behavior

People have made exactly this case in favor of allowing implicit coercion from undefined to 0 in bitwise ops #22230

"No implicit coercions, except for the ones I personally like" is just a very difficult line to draw

I can see the potential tangential relationship. However, I’m trying to keep this issue more focused specifically on comparisons (>, <, >=, <=), not any operations.

When comparing this to something like the bitwise operators one, the question becomes, how should undefined behave in a bitwise operation? It’s not expressly clear, thinking about the question mathematically or logically. What should undefined | 2 be? What should undefined & 2 be? You don’t expressly know. But with comparison operators, it does follow logic. What should undefined < 2 be? What should undefined > 2 be? You’d expect them to be false, and they do evaluate to false just as expected.

I wouldn’t be a proponent of allowing mixed types here, just allowing types to be compared to the undefined-able version of themselves. We already allow implicit coercion to number when doing comparisons between the same types (string to string, Date to Date).

The only proposed change is to allow types to be compared to themselves or undefined, since the coercion of undefined follows the logical expectation of evaluating to false in comparisons (except for <= and >= between two undefineds).

I’d be fine with only one side being optional, but that would be extra complication that I don’t want to be married to here.

@ericbf
Copy link
Contributor Author

ericbf commented Feb 7, 2025

For example, we allow optional types in equality checks. That presents the same issue, but we still have type checking to show that score is optional.

if (student.score === 100) {
    console.log("Wow, perfect score!")
}

Versus

if (student.score !== 100) {
    console.log("You didn’t get a perfect score.")
} else {
    console.log("Wow, perfect score!")
}

@ericbf
Copy link
Contributor Author

ericbf commented Feb 8, 2025

What workarounds are you using in the meantime?
Having to do some ugly constructs to conditionally evaluate heavy functions and save to variables, then use that optional variable instead of simply evaluating the function in the comparison. It’s way more verbose than just allowing undefined in comparisons.

You can just use the non-null assertion operator:

declare const value: number | undefined;
if (value! > 123) {}

Regarding using the non-null assertion—ignoring the fact that this would be an incorrect assertion (since the value could very well be undefined), we have lint rules in place in our company that disallows non-null assertions since they can and do lead to hard to debug errors.

While I appreciate the response, work-arounds like "you can just use non-null assertions," "you can just use a @ts-ignore," and etc are not super useful. Essentially every issue having to do with strictNullChecks can be worked around with non-null assertions. That’s not the point here.

@ericbf
Copy link
Contributor Author

ericbf commented Feb 8, 2025

I created this issue to isolate the undefined case from the null case in comparisons, since undefined actually plays nice with comparisons in JavaScript. By coercing to NaN, it resolves to false like you’d expect when used in comparisons. Contrast that with null, which coerces to 0, making it dangerous and unusable in comparisons.

The point here is that since the undefined case plays nice, it should be allowed without error. The null case does not play nice, so its not being allowed is justified.

If nothing else, I’m saying that using an undefined in a comparison should not be a compilation error, even with strictNullChecks. Perhaps a warning, or even another option (though I despise the idea of wanting new options for every little feature requested).

@snarbles2
Copy link

snarbles2 commented Feb 10, 2025

I don't really understand the argument that undefined coercing to NaN is right and null coercing to 0 is wrong. How is a result of false for an invalid comparison better than simply disallowing the comparison? If I'm performing a comparison against undefined, that means I have a logic error somewhere and I absolutely want to be informed of that.

If it may be undefined, and you want to treat it as a number, that's exactly specifically what the ! operator is for. It's not a "workaround". Ultimately you're arguing that the language change because you have a linter rule set up to forbid the correct solution.

Edit: re:

For example, we allow optional types in equality checks.

IIRC there is an open issue requesting this be changed. It's allowed in order to permit defensive programming, but it can and does cause problems, and there is the view (which I agree with) that checking for invalid data should be done at the boundaries of your environment instead of hoping you catch it downstream.

@tylerc
Copy link

tylerc commented Feb 10, 2025

I've seen several projects migrate from JavaScript to TypeScript, and JS code is very often not strict with using null vs undefined.

What's x?: number in one part of a codebase is often treated as x?: number | null in other parts, and until you've completed the migration some of these annotations may be incorrect.

So it's very helpful for TypeScript to say "hey, this value may be absent - you should handle that", regardless of whether that absence is indicated with null or undefined.

I think the FAQ entry "The ECMAScript Spec is Descriptive, not Normative" really applies here: https://github.com/microsoft/TypeScript/wiki/FAQ#the-ecmascript-spec-is-descriptive-not-normative

Just because the implicit coercions are defined, doesn't mean I want them.

@ericbf
Copy link
Contributor Author

ericbf commented Feb 11, 2025

I don't really understand the argument that undefined coercing to NaN is right and null coercing to 0 is wrong.

By null coercing to 0, it makes its use in comparisons evaluate to true in many potential use cases. For example, would you say that null < 1? No, null is null. Would you say that null >= 0? No, it’s not, it’s null. But those both evaluate to true. Obviously that’s not correct. That doesn’t quite make sense. So I think it’s clear why coercing to 0 is "wrong", logically speaking (the fact of the matter is, though, that null does coerce to 0). As for coercing to NaN, I would say that’s "right" logically because when used in comparisons, it tends to result in what you’d expect. Is undefined < 1? No. Is undefined >= 0? No. Is undefined <= 0? No. And they do evaluate to false.

How is a result of false for an invalid comparison better than simply disallowing the comparison?

The point is that it’s not an invalid comparison. It’s a perfectly valid comparison. Let’s say you are writing a paper that you wanted to publish in 2024. If you were to be asked, "Hey, that paper you were writing, did you get it published before the start of 2025?" (essentially paper.publicationDate < "2025-01-01"—let’s just use ISO date string for simplicity). If the paper hasn’t been published yet, you wouldn’t clap back "what a stupid question, I can’t answer that, since the paper hasn’t been published yet at all!". Since your paper doesn’t have a publication date (it’s undefined), the answer is false. No one would be confused by the question "was it published before the start of 2025?" if the paper hadn’t been published yet. Perfectly valid comparison.

In practice, this becomes very useful when used in contexts where you have known optional values, maybe when you don’t control the source (it comes from an API, etc), like optionalArray?.length > 3, it gets tedious to say optionalArray != null && optionalArray?.length > 3. Saying optionalArray?.length! > 3 makes no sense—how can we assert that an optional array has a non-null length? It’s a misuse of the non-null assertion operation. In contrast optionalArray!.length > 3 would actually result in a run-time error, so you can’t do that either. The only resource is to add some tedious workarounds to accommodate the dogmatics of the language.

If I'm performing a comparison against undefined, that means I have a logic error somewhere and I absolutely want to be informed of that.

No logic error necessary, as we covered above. And that's what lint rules are for. If you personally want to be more restrictive in your comparisons, use a lint rule that checks that in your projects. Why should it throw an error because you would like it to fail there in your project? What you may consider an "invalid" comparison, others may not consider invalid (with good reason).

If it may be undefined, and you want to treat it as a number, that's exactly specifically what the ! operator is for. It's not a "workaround". Ultimately you're arguing that the language change because you have a linter rule set up to forbid the correct solution.

It’s called the non-null assertion operator, not the accept a nullable value here operator. Knowing that the number can be undefined, but using the non-null assertion on it is just wrong. The value is not non-nullable—actually the point is that it is nullable (or undefined-able in this case), and we want it to still be usable there.

What I’m arguing is that the language should not take a dogmatic stance on something that isn’t so cut and dry. Leave it either to the linters, or (despite the undesirability of new options) an option. It behaves the way you’d expect, evaluating to false if used in comparisons. So why should it throw an error here?

Edit: re:

For example, we allow optional types in equality checks.

IIRC there is an open issue requesting this be changed. It's allowed in order to permit defensive programming, but it can and does cause problems, and there is the view (which I agree with) that checking for invalid data should be done at the boundaries of your environment instead of hoping you catch it downstream.

There’s also strict-boolean-expressions for that, but there’s no recourse if need undefined to be allowed in comparisons, for whatever business or personal reason. So we have this issue here.

All of that said, I can see the value of restricting it—for users to be warned, or even want a failure in these cases, if the compiler allows it, they would feel like the compiler is too loose. I tend to take the position that the compiler should be more permissive, and if users want more strict rules, they can use lint rules (or stricter compiler options) on their projects. For this specific feature, the only recourse to use when the workarounds are either not satisfactory or not allowed by company, team, or personal restrictions, is completely disabling strictNullChecks, which comes with a lot more than just allowing undefined values in comparisons.

@ericbf
Copy link
Contributor Author

ericbf commented Feb 11, 2025

For my specific use case, I’m having to use a third party GraphQL API where they have a lot of deeply nested fields, under a lot of optional layers, and it becomes a huge hassle. For each if statement where we need to use a comparison (there are a surprising number of them), having to check != null or whatever after using four, five, plus layers of optional chaining, then repeating those again to access the actual value to compare, it gets very repetitive. Or declaring single-use variables just to check it twice in the same if statement (!= null then > 10 or whatever). I tend to avoid single use variables, as they often just add extra redirection that can make code harder to follow (obviously not always).

@ericbf
Copy link
Contributor Author

ericbf commented Feb 11, 2025

I've seen several projects migrate from JavaScript to TypeScript, and JS code is very often not strict with using null vs undefined.

What's x?: number in one part of a codebase is often treated as x?: number | null in other parts, and until you've completed the migration some of these annotations may be incorrect.

So it's very helpful for TypeScript to say "hey, this value may be absent - you should handle that", regardless of whether that absence is indicated with null or undefined.

I think the FAQ entry "The ECMAScript Spec is Descriptive, not Normative" really applies here: https://github.com/microsoft/TypeScript/wiki/FAQ#the-ecmascript-spec-is-descriptive-not-normative

Just because the implicit coercions are defined, doesn't mean I want them.

I appreciate this sentiment. My point here is that the behavior of undefined in comparisons is not incorrect, but actually correct. And I propose that it actually is a valid comparison and use-case to compare an optional (... | undefined) value in a comparison, so should be allowed. This comes up most often after optional chaining in my experience, but also could be from functions that return optional values or etc. I don’t want to turn this into a cat-and-mouse of just listing off a bunch of use cases. There are obviously many, and each has potential work-arounds—this isn’t a blocker by any means.

That said, the fact that much JS code isn’t strict about null vs undefined does not mean that we shouldn’t treat them as distinct values. They very much are distinct values and behave very differently. For example, anything after an optional chain (absent an actual null value in whatever field you are accessing) will be "something" or undefined. So should we say ... | undefined | null in TypeScript, because a lot of JS code isn’t strict about it? Obviously not. So for sure we should have behaviors specifically for undefined and not for null, or vice versa. And we do—you can’t pass null to an optional parameter that doesn’t specifically accept null, for example.

@ericbf
Copy link
Contributor Author

ericbf commented Feb 11, 2025

I perceive this is an uphill battle, so I’ll say this—this isn’t a hill I’m willing to die on—it’s just an annoyance that I figured I’d open an issue for. The issues that are already open that are related to this seem to group together null and undefined as if they are comparable (no pun intended) when used in comparisons. My main point here is that they are not. While undefined can actually makes sense in a comparison, null does not.

@kirkwaiblinger
Copy link

kirkwaiblinger commented Feb 11, 2025

Just tossing out there for the discussion, technically I guess there's a few other recourses...

Nullish coalesing

if ((a.long?.optional.chaining?.expression ?? Infinity) <= 24) { }

Kinda yucky since you have to reason about the default value, but removes the ambiguity about both nullish cases, including possible null/undefined conflation.

IIIFE

Tightly scoping a single-use intermediate variable via an IIFE

const someObject = {
   resultOfComparison: (() => {
       const theThing = a.long?.optional.chaining?.expression;
       return theThing != null && theThing <= 24;
   })()
// cannot access intermediate variable afterwards.
}

Neither of these is as concise, to be sure, but neither depending on deep knowledge of coercion.


Suppress TS

I would agree with the point that using the non-null assertion operator is quite undesirable. I'd just hit the code with a TS error suppression if i really wanted to write this code

// @ts-expect-error -- I know what happens when `undefined` is used in a comparison.
if (maybe.undefined.length >= 2) {
}

but of course that is an extremely blunt instrument.


The other thing to think about is how understandable is a case where both sides of the comparison might be undefined? Seems like a rather unpleasant situation to reason about.

if (maybeUndefined < alsoMaybeUndefined) {}

@ericbf
Copy link
Contributor Author

ericbf commented Feb 12, 2025

Nullish coalesing

if ((a.long?.optional.chaining?.expression ?? Infinity) <= 24) { }
Kinda yucky since you have to reason about the default value, but removes the ambiguity about both nullish cases, including possible null/undefined conflation.

I actually do like this solution more than the others. Still not as concise as just allowing it to happen implicitly, but it’s OK. For further explicitness, I’ll probably start coalescing to NaN, which is what would happen implicitly anyways.

if ((a.long?.optional.chaining?.expression ?? NaN) <= 24) { }

@guillaumebrunerie
Copy link

The point is that it’s not an invalid comparison. It’s a perfectly valid comparison. Let’s say you are writing a paper that you wanted to publish in 2024. If you were to be asked, "Hey, that paper you were writing, did you get it published before the start of 2025?" (essentially paper.publicationDate < "2025-01-01"—let’s just use ISO date string for simplicity). If the paper hasn’t been published yet, you wouldn’t clap back "what a stupid question, I can’t answer that, since the paper hasn’t been published yet at all!". Since your paper doesn’t have a publication date (it’s undefined), the answer is false. No one would be confused by the question "was it published before the start of 2025?" if the paper hadn’t been published yet. Perfectly valid comparison.

"Did you get it published before the start of 2025?" is actually two questions: "Did you get it published?" and "Is the publication date before the start of 2025?". So paper.publicationDate !== undefined && paper.publicationDate < "2025-01-01". Asking "Is the publication date before the start of 2025?" is nonsensical for something that does not have a publication date.

But this example is a bit confusing because you can’t retroactively publish a paper. If instead you already published the paper and you were to be asked "Did you manage to publish the paper before the deadline?", would you answer "No" if there was actually no deadline? If there is no deadline, surely that means every paper was published "before the deadline", right?

For further explicitness, I’ll probably start coalescing to NaN, which is what would happen implicitly anyways.

I would personally find it much more explicit to default to Infinity, as it is obvious and mathematically correct that Infinity <= 24 evaluates to false, while it is not obvious and mathematically meaningless what NaN <= 24 evaluates to.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

7 participants