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

Treat expr satisfies never; similarly to a call to a never-returning function in CFA #61004

Open
6 tasks done
uhyo opened this issue Jan 21, 2025 · 9 comments
Open
6 tasks done
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript

Comments

@uhyo
Copy link
Contributor

uhyo commented Jan 21, 2025

πŸ” Search Terms

Control flow analysys, CFA, satisfies, never, return, termination, assert

βœ… Viability Checklist

⭐ Suggestion

In CFA, expr satisfies never; statements should be treated similarly to a call to a never-returning function, treating statements that follow as unreachable.

I'm suggesting that TS enhance the expr satisfies never; pattern which is one of the ways to do the exhaustiveness check. It is a very popular technique used when dealing with discriminated union types. The ways often used are:

// 1-a. assign to a never-typed variable and abort
const _exhaustivenessCheck: never = option;
throw new Error("unreachable");

// 1-b. return never instead of abort
const _exhaustivenessCheck: never = option;
return _exhaustivenessCheck;

// 2. Call a function that accepts never
assertNever(option);

// 3-a. Use the satisfies syntax and abort
option satisfies never;
throw new Error("unreachable");

// 3-b. Use the satisfies syntax and return
option satisfies never;
return option;

Currently, 1 and 3 requires either throwing or returning to terminate the current (unreachable) execution branch. On the other hand, 2 automatically terminates it (assuming that the return type of assertNever is never).

My suggestion is that 3 (option satisfies never) also work as a termination point of the current execution branch, so that the following would be possible:

// 3-c (suggested behavior). Use the satisfies syntax and that's it!
option satisfies never;
/* code here is considered unreachable */

I think expr satisfies never; has several advantages compared to others:

Also, notably, expr satisfies never; is explicit enough for the compiler to reason about as part of the CFA.

In addition, I think this is also good from a theoretical perspective; never-returning functions are currently treated as a termination point of an execution branch because such functions can never return anything. Actually, this is not specific to functions. If you somehow have a value of type never, you have proven that this code isn't actually executed. expr satisfies never; feels like a good, sensible way to declare that you have a proof.

πŸ“ƒ Motivating Example

type Option<T> = {
  tag: "Some";
  value: T;
} | {
  tag: "None";
}


function optionToString(option: Option<unknown>): string {
  let value: string;
  switch (option.tag) {
    case "Some": {
      value = `Some(${option.value})`;
      break;
    }
    case "None": {
      value = "None";
      break;
    }
    default: {
      option satisfies never;
      // ↑ should have the same effect as:
      // assertNever(option);
    }
  }

  // Current behavior: error because `value` may not have been assigned.
  // Suggested behavior: no error here.
  return `Option(${value})`;
}

function assertNever(value: never): never {
  throw new Error("unreachable");
}

Playground

πŸ’» Use Cases

1. What do you want to use this for?

As shown above, for exhaustiveness checks.

2. What shortcomings exist with current approaches?

Live with less optimal (from different aspects) ways.

3. What workarounds are you using in the meantime?

assertNever(option);
@uhyo uhyo changed the title Treat expr satisfies never; similarly to a call to a never-returning function Treat expr satisfies never; similarly to a call to a never-returning function in CFA Jan 21, 2025
@MartinJohns
Copy link
Contributor

This suggestion would break this common use case:

type Value = "a" | "b"

function isValidValue(value: unknown): value is Value {
    const asValue = value as Value;
    switch (asValue) {
        case "a":
        case "b":
            return true;
        
        default:
            asValue satisfies never;
            return false;
    }
}

@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript In Discussion Not yet reached consensus labels Jan 21, 2025
@uhyo
Copy link
Contributor Author

uhyo commented Jan 22, 2025

This suggestion would break this common use case:

I'm personally OK with the breakage. In this case one would stop using asValue satisfies never; and change to another way. That code makes a false assumption using as. I see why this is useful, but I think it is normal for code that makes a false assumption to fall behind other code.

@kirkwaiblinger
Copy link

Strong +1 to the objection in #61004 (comment)... A slight variant is a standard pattern for ensuring exhaustive switches in all cases (not only after a known-unsafe as)

// maybe declared in another file, or even a 3rd party library.
type SomeUnionType = "a" | "b";

declare const x: SomeUnionType;
switch (x) {
  case "a":
    doOneThing();
    break;
  case "b":
     doAnotherThing();
     break;
  default:
    // ensure a TS error if another case is added to `SomeUnionType`, quite possibly with no diff to this file
    x satisfies never;
    // ensure a runtime error if the types are wrong at runtime (e.g. because package versions don't match, etc.)
    throw new Error("Unexpected switch value: " + String(x));
}

My mental model as a user is that satisfies expressions simply are not control flow statements. They're constraints on the type system whose only effect is to produce a type error in the case of a constraint violation (that is to say - they don't even have the ability to change the type of anything - just produce errors). This proposal would produce unsafe control flow analysis changes, prohibiting the main, safe uses of satisfies never (validating exhaustiveness according to the types before handling cases that defy the types at runtime).

Noting that the proposal also undermines itself:

      option satisfies never;
      // ↑ should have the same effect as:
      // assertNever(option);

yet, the example assertNever() is not a noop! The equivalent would be

function noopMarkExhaustiveForTsCfa(x: never): never {
  // why would you not throw, or at least log, here?
  // non-exception behavior is Undefined Behavior in the program
}

switch (option) {
  // ...
  default:
   noopMarkExhaustiveForTsCfa(option);
}

@uhyo
Copy link
Contributor Author

uhyo commented Jan 25, 2025

Noting that the proposal also undermines itself:

I thought you were wise enough to understand I meant the effect to CFA. Otherwise how can I explicitly mention that expr satisfies never; is no-op as an adgantage? It's disappointing to see you doing a falutfinding like this.

That said, it's my bad to have used that implementation of assertNever as an example. πŸ˜… Actually my most preferred implemention is:

function assertNever(value: never): never {
  return value;
}

I actually understand your objection. Especially when using TS to write a library, it may be reasonable to assume that types are not respected by users. So I view this as one of those applications VS libraries stuff.

My opinition is rather biased to the application side. On this premise I still believe it's nice to let users believe types TS infers for you.

TS already does type-based checks like flagging comparisions that always return true as error, and so on. I'd like to see expr satisfies never; added to that family.

@kirkwaiblinger
Copy link

kirkwaiblinger commented Jan 25, 2025

I thought you were wise enough to understand I meant the effect to CFA. Otherwise how can I explicitly mention that expr satisfies never; is no-op as an adgantage? It's disappointing to see you doing a falutfinding like this.

hehe, I'll grant you that that was a bit of a cheap shot (intended to be a little tongue-in-cheek). I definitely respect that you have a thorough understanding of the topic!

I actually understand your objection. Especially when using TS to write a library, it may be reasonable to assume that types are not respected by users. So I view this as one of those applications VS libraries stuff.

My opinition is rather biased to the application side. On this premise I still believe it's nice to let users believe types TS infers for you.

The canonical counterexample though is on the application side.

function handleDataFromServer(x: SomeEnumThatTheServerShouldBeSending) {
   switch (x) {
     // etc
     default: {
       x satisfies never;
       console.error("unexpected case ", x);
     }
   }
}

The point is that you might write an enum with known members that the server should be sending, but you still potentially need to handle not-yet-accounted-for new enum values since the server can add an enum value without it being a breaking change.

@kirkwaiblinger
Copy link

Since TS doesn't have a distinction between exhaustive and non-exhaustive enums, it's important to be able to require a program to handle all known cases (via satisfies never), but be allowed to write code handling unknown cases, rather than it being analyzed as unreachable code (a TS error in most projects)

@uhyo
Copy link
Contributor Author

uhyo commented Jan 25, 2025

Ah, sorry for not understanding your point. I didn't think of that because I'd always validate value from potentially outdated/newer sources before it enters the type safe world.

But I understand that's a too opinionated thought to support a suggestion like this. I'm still interested in what TS Team would say on this.

@kirkwaiblinger
Copy link

Ah, sorry for not understanding your point. I didn't think of that because I'd always validate value from potentially outdated/newer sources before it enters the type safe world.

Yeah this is definitely the right way to code πŸ™‚! But unfortunately it's common not to use enums this way in the wild.

FWIW - my points on this issue are all specifically about the use of the satisfies syntax... maybe there is (or can be made) another syntax to mark control flow unreachability without a runtime emit? 🀷

Cheers!

@uhyo
Copy link
Contributor Author

uhyo commented Jan 29, 2025

maybe there is (or can be made) another syntax to mark control flow unreachability without a runtime emit? 🀷

Nice idea! Random thought:

declare value is never;

πŸ™ƒ πŸ€”

Maybe declare value satisfies never; would be fine too but users would be confused as to when to declare or not.

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

4 participants