Skip to content

Opt-in type checking of type safe type predicates #57676

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

Closed
6 tasks done
Chamion opened this issue Mar 7, 2024 · 5 comments
Closed
6 tasks done

Opt-in type checking of type safe type predicates #57676

Chamion opened this issue Mar 7, 2024 · 5 comments

Comments

@Chamion
Copy link

Chamion commented Mar 7, 2024

πŸ” Search Terms

"predicate", "safe predicate", "narrowed", "infer predicate"

βœ… Viability Checklist

⭐ Suggestion

A new keyword to indicate the type checker should emit errors if the type predicate is not type safe.

(value: A): value is narrowed B => value.kind === 'B';

The type checker should emit errors if

  1. If B is not assignable to the narrowed type of value everywhere the function returns true.
  2. If Exclude<A, B> is not assignable to the narrowed type of value everywhere the function returns false.

I'm not sure what the keyword should be called but narrowed is the best I've come up with so far. If placed outside a type predicate node the token would be an identifier instead like it already is. Identifiers are not allowed following an is keyword so the scanner can tell them apart. The keyword would be represented as a boolean property of the type predicate node the same way assserts already is.

πŸ“ƒ Motivating Example

Type safe type predicates are already possible in >=4.9. This is the above proposed syntax translated to what I'd write in current TypeScript.

(value: A): value is B =>
  value.kind === 'B'
    ? (value satisfies B, true)
    : (value satisfies Exclude<A, B>, false);

The syntax is unfortunate to put it politely. A developer who has never seen a narrowing type predicate before may be confused. I also need to add more JavaScript to apply the type constraints.

Why not inline it? Inlining works but if I do the same type checks in multiple places I want to extract it to a function to avoid repetition. All type predicates must be annotated but unfortunately the annotation also makes it type unsafe and that can lead to regressions. Here's a contrived example.

type Media = SquareImage | TwoByThreeImage | SquareVideo;
type Image = SquareImage | TwoByThreeImage;

const isImage = (media: Media): media is Image => {
  switch (media.kind) {
    case '1x1_image':
    case '2x3_image':
      return true;
    default:
      return false;
  }
};

Now if we change the definitions of Media and Image there's no errors from TS if I forget to add the new switch case.

type Media = SquareImage | TwoByThreeImage  | PortraitImage | SquareVideo;
type Image = SquareImage | TwoByThreeImage | PortraitImage;

πŸ’» Use Cases

The main use is for type predicates that are the result of extracting a type checking expression into a function. Not all type predicates should be type safe. There are valid uses of type-unsafe type predicates and that would remain the default. In my unscientific survey through my company's code about half of our type predicates are narrowing or could be.

Alternatives

Inferred type guards

There's been demand for inferred type guards for a long while now: #38390. If sufficiently complex type guards can be inferred, the narrowed keyword would not be needed as we could write something like the following.

const predicate = ((value: A) => value.kind === 'B') satisfies (value: A) => value is B;

External tools

I've already written a lint rule to make maintaining narrowing type predicates easier. I intend to extend it with a pair of codemods which would allow me to use the following syntax.

(value: A): value is /* narrowed */ B => value.kind === 'B';

I could then

  1. Codemod transform functions with the comment directive to narrowing type predicates
  2. Run type check
  3. Codemod to revert

That's a hacky approach and ultimately impossible to integrate into a code editor so I'd only get type errors in CI at best.

If I could write the feature directly as a language feature I'd save myself that trouble and get a better end result, too. I'm interested in contributing but if the proposal is denied I'll write my codemods instead.

@MartinJohns
Copy link
Contributor

In general type predicates are written for code that the compiler can't figure out itself, so I'm not sure how feasible this suggestion even is.

For your example you could improve the code by listing every possible case in the switch (with the respective return values), and call an assertion function accepting a never argument in your default block. This way the developer is forced to explicitly acknowledge the newly added case, otherwise the value is not narrowed to never and the assertion function can't be called.

@ahejlsberg
Copy link
Member

It seems #57465 already covers some of this.

@Chamion
Copy link
Author

Chamion commented Mar 7, 2024

For your example you could improve the code by listing every possible case in the switch (with the respective return values), and call an assertion function accepting a never argument in your default block.

That's what we used to do before 4.9.

const isImage = (media: Media): media is Image => {
  switch (media.kind) {
    case '1x1_image':
    case '2x3_image':
    case '2x1_image':
      return true;
    case '1x1_video':
      return false;
    default:
      assertNever(media);
  }
};

assertNever can be replaced with a media satisfies never expression. Either way now there's even more JavaScript required to apply the type constraint. This does help in this specific example but it's not type safe. I would write it as a narrowing type predicate in my own code:

const isImage = (media: Media): media is Image => {
  switch (media.kind) {
    case '1x1_image':
    case '2x3_image':
    case '2x1_image':
      return (media satisfies Image, true);
    default:
      return (media satisfies Exclude<Media, Image>, false);
  }
};

@Chamion
Copy link
Author

Chamion commented Mar 7, 2024

Writing this suggestion I thought type predicate inference might not ever become a language feature. However, the activity around #57465 implies that is the direction the language is headed and I expect there will be more robust inference to follow.

When a type predicate can be inferred it can also be type checked with a type constraint: satisfies (value: A) => value is B. This practically replaces the narrowing type predicate syntax for simple cases that can be inferred. For complex cases we still have the current narrowing type predicate syntax: (value satisfies B, true), (value satisfies Exclude<A, B>, false). It's still not pretty but as more type predicates are inferred it will be needed less.

Ultimately, in a state where type predicates are perfectly inferred, the narrowed keyword would be useless. I'm closing this issue because the design of this suggestion is partially in conflict with inferred type predicates and I think we are better off putting our efforts towards better type predicate inference going forward. The more type predicates can be inferred the less we need something like this.

@Chamion Chamion closed this as completed Mar 7, 2024
@bgenia
Copy link

bgenia commented Jun 23, 2024

Can this be revisited?

The proposed feature is already here in a form of:

const isNumber: (value: unknown) => value is number = (value) => typeof value === "string"
// Type '(value: unknown) => value is string' is not assignable to type '(value: unknown) => value is number'.
//   Type predicate 'value is string' is not assignable to 'value is number'.
//     Type 'string' is not assignable to type 'number'.

What's missing is a proper syntax for function declarations, so we don't have to jump through these hoops every time a checked type guard is needed.

Ultimately, in a state where type predicates are perfectly inferred, the narrowed keyword would be useless.

Since isolated declarations are a thing now, you can't always rely on inference - 3rd party tools will require annotations.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants