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

Type assertions & type predicates can have incorrect narrowing logic #57436

Closed
Masstronaut opened this issue Feb 19, 2024 · 9 comments
Closed
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug

Comments

@Masstronaut
Copy link

Masstronaut commented Feb 19, 2024

🔎 Search Terms

Type assertion, type predicates, type narrowing

🕗 Version & Regression Information

  • This is the behavior in every version I tried, and I reviewed the FAQ for entries about assertion functions, type predicates, and narrowing. I failed to find anything covering type predicates and assertion functions.

⏯ Playground Link

see on TS playground

💻 Code

type Animal = { legs: number };
type Cat = Animal & { legs: 4, meow: () => void };
type Bird = Animal & {legs: 2, chirp: () => void };

// This function doesn't have sufficient logic to narrow from `Bird | Cat` to `Bird`
function isBird(pet: Bird | Cat): pet is Bird {
	return true;
}
// This function doesn't have sufficient logic to narrow from `Bird | Cat` to `Bird`
function isBirdAssertion(pet: Bird | Cat): asserts pet is Bird {
}

function insufficientAssertions(pet: Bird | Cat){
  if(isBird(pet)){
    pet.chirp();
  }
  isBirdAssertion(pet);
  pet.chirp()
}

// The typescript compiler clearly has the means to handle narrowing between these types:
function validNarrowing(pet: Bird | Cat){
  // the same logic as `isBird` is known to be insufficient to narrow the type of `pet`
  if(true){
    // @ts-expect-error: 2339
    pet.chirp();
  }

  if(pet.legs === 2){
    // With proper narrowing logic, the compiler knows the type can be narrowed to `Bird`
    pet.chirp();
  }
}

🙁 Actual behavior

An assertion function or type predicate which contains insufficient logic to narrow the input type generates no errors and can be used to narrow a type in the calling code, despite not having certainty the type is narrower.

🙂 Expected behavior

an assertion function or type predicate which doesn't sufficiently narrow the input type before the return or end of function should result in an error.

Additional information about the issue

No response

@fatcerberus
Copy link

The primary reason to write a UDTG is to do type checks that the compiler isn't capable of doing itself, so it's very much intentional that the implementation isn't checked. Otherwise you'd get spurious errors in cases where the logic is sufficient for narrowing but the compiler isn't able to prove it.

@Masstronaut
Copy link
Author

Masstronaut commented Feb 19, 2024

The primary reason to write a UDTG is to do type checks that the compiler isn't capable of doing itself, so it's very much intentional that the implementation isn't checked. Otherwise you'd get spurious errors in cases where the logic is sufficient for narrowing but the compiler isn't able to prove it.

Could you share an example? The TS handbook doesn't seem to imply that, and in fact gives an example similar to mine that the compiler definitely was capable of doing itself. See "using type predicates" for example.

I have yet to encounter a scenario where it is more useful for type predicates to not also have the compiler perform the checks it can. I've also seen many cases of people having the same mistaken understanding I do, so perhaps it would be beneficial to update the handbook to use such an example. I'd be happy to make that PR if I had such an example to propose.

@fatcerberus
Copy link

I have yet to encounter a scenario where it is more useful for type predicates to not also have the compiler perform the checks it can.

You’re basically asking TS to spit out an error whenever it thinks that you “haven’t done enough” to narrow the value according to the function signature, via the checks it’s able to do itself. Lots of non-trivial type guards would be broken by such an error; see #29980 (comment).

tl;dr: The proposed behavior would error on any type predicate where the same check wouldn’t already work to narrow the value inline. In other words, almost all of them.

@RyanCavanaugh RyanCavanaugh added the Working as Intended The behavior described is the intended behavior; this is not a bug label Feb 20, 2024
@RyanCavanaugh
Copy link
Member

The docs should probably be clearer about this

@tadhgmister

This comment was marked as off-topic.

@tadhgmister
Copy link

tadhgmister commented Feb 20, 2024

example that may be useful for docs:

interface SuccessStatus {
    code: 0;
    message: string;
}
interface FailureStatus {
    /* in failure case code will be anything other than 0 */
    code: number;
    reason: string;
}
type ProcessStatus = SuccessStatus | FailureStatus;

function isSuccess(response: ProcessStatus): response is SuccessStatus {
    return response.code === 0;
}

declare const a: ProcessStatus;

if (a.code === 0){
    // this doesn't work since typescript can't determine that failure case can't be 0
    console.log(a.message); 
}
if (isSuccess(a)){
    console.log(a.message); // works as intended.
}

@typescript-bot
Copy link
Collaborator

This issue has been marked as "Working as Intended" and has seen no recent activity. It has been automatically closed for house-keeping purposes.

@Masstronaut
Copy link
Author

example that may be useful for docs:

interface SuccessStatus {
    code: 0;
    message: string;
}
interface FailureStatus {
    /* in failure case code will be anything other than 0 */
    code: number;
    reason: string;
}
type ProcessStatus = SuccessStatus | FailureStatus;

function isSuccess(response: ProcessStatus): response is SuccessStatus {
    return response.code === 0;
}

declare const a: ProcessStatus;

if (a.code === 0){
    // this doesn't work since typescript can't determine that failure case can't be 0
    console.log(a.message); 
}
if (isSuccess(a)){
    console.log(a.message); // works as intended.
}

@tadhgmister In the example you shared the type could be narrowed safely:

interface SuccessStatus {
    code: 0;
    message: string;
}
interface FailureStatus {
    /* in failure case code will be anything other than 0 */
    code: number;
    reason: string;
}
type ProcessStatus = SuccessStatus | FailureStatus;

function isSuccess(response: ProcessStatus): response is SuccessStatus {
    return 'message' in response;
}

declare const a: ProcessStatus;

if (a.code === 0){
    // this doesn't work since typescript can't determine that failure case can't be 0
    console.log(a.message); 
}
if ('message' in a){
    // this works and is verifiable by the compiler
    console.log(a.message);
}
if (isSuccess(a)){
    console.log(a.message); // works as intended.
}

It seems like it would be preferable to have the narrowing occur safely, and that seems to be the standard expectation of TS developers who are unfamiliar with the current behavior.

@tadhgmister
Copy link

@Masstronaut then consider a case where message and reason are both optional. (Playground Link) I do think my original example is sufficient to get the point across but I respect your need for an example that has no alternative.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug
Projects
None yet
Development

No branches or pull requests

5 participants