gvergnaud / ts-pattern

🎨 The exhaustive Pattern Matching library for TypeScript, with smart type inference.
MIT License
12.05k stars 128 forks source link

Allow otherwise to chain onto exhaustive #144

Open Liam-Tait opened 1 year ago

Liam-Tait commented 1 year ago

Is your feature request related to a problem? Please describe. A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]

We have types, however they are not completely safe. I'd like to be able to ensure the exhaustive type check is run for case such as union where we want to handle where , but they also run an otherwise to prevent throwing of an error.

This means we can have the type checking where a type is partially complete.

Describe the solution you'd like A clear and concise description of what you want to happen.

Add a new method exhaustiveOtherwise which does the exhaustive type check with a otherwise handler


const unit: 'days' | 'hours' = 'something'

match(unit)
.with('days', () => 'd')
.with('hours', () => 'h')
.exhaustiveOtherwise(() => 'never') // safety to prevent error

Describe alternatives you've considered A clear and concise description of any alternative solutions or features you've considered.

Allow otherwise to chain onto the end of exhaustive


const unit: 'days' | 'hours' = 'something'

match(unit)
.with('days', () => 'd')
.with('hours', () => 'h')
.exhaustive()
.otherwise(() => 'never')

Add validation such as zod to the data to handle incorrect data (hard as I would like to transition new code to be type safe without having to re-write all types)

Additional context Add any other context or screenshots about the feature request here.

gvergnaud commented 1 year ago

Hey! I think I'd use .exhaustive() and wrap the expression into a try .. catch to get the behavior you expect. Would that solve your problem?

eboody commented 1 year ago

Im kind of confused about this use-case. In what context would you have a scenario that matches your example of

const unit: 'days' | 'hours' = 'something'

?

Liam-Tait commented 1 year ago

Wrapping the expressions in try .. catch would work, but makes the code verbose and a bit more complicated because of scoping

const value = match(unit)
  .with('days', () => 'd')
  .with('hours', () => 'h')
  .exhaustiveOtherwise(() => 'never')

vs

let value: string
try {
  value = match(unit)
    .with("days", () => "d")
    .with("hours", () => "h")
    .exhaustive()
} catch (error) {
  value = "never"
}

What I am working with is not an ideal case, the application has partially transitioned to TypeScript. Some types are only partially correct right now.

Adding a catch or otherwise that works with exhaustive means I can start taking advantage of exhaustiveness checking, while preventing errors at runtime while types cannot be fully trusted. Maybe the ideal approach is to have everything be unknown and handle it that way.

I don't know if this is possible, but having a P.unknown matcher that only matched unknown would also work, as then I could add this to types that are not complete yet, this would more accurately show and handle my current situation

const unit: 'days' | 'hours' | unknown;
const value = match(unit)
  .with("days", () => "d")
  .with("hours", () => "h")
  .with(P.unknown, () => 'never')
  .exhaustive()

I want to be able to:

One of the situations I have is where data is persisted in local storage and loaded back in without any validation, the types "should" be the same, but can be different based on an old stored version, renaming, user changing etc

XiNiHa commented 1 year ago

How about just adding an optional argument to .exhaustive() that works as same as .otherwise()? So just like:

declare const input: 'a' | 'b';
const mode = match(input)
  .with('a', () => Mode.A)
  .with('b', () => Mode.B)
  .exhaustive(() => Mode.Fallback)

In addition to that, it'd be better to have the value passed to the callback as unknown, (since it's impossible to infer valid type in this case) so users can use their own errors than the provided one:

declare const input: 'a' | 'b';
const mode = match(input)
  .with('a', () => Mode.A)
  .with('b', () => Mode.B)
  .exhaustive(v => {
    throw new ValidationError(`Unknown mode identifier: ${v}`)
  })
eboody commented 1 year ago

I dunno it looks to me like there's a fundamental disagreement about what "exhaustive" means in this context.

In my opinion "exhaustive" loses it's meaning if it can be used in a scenario where you need a fall back

Also, as I understand it there is a performance cost to using exhaustive, so I think it's purpose is pretty narrowly defined.

If you're getting input that you can't type narrow into exhaustive checks then I think there's a strong case to be made that it would be inappropriate to use exhaustive from a conceptual as well as a performance standpoint.

LavransBjerkestrand commented 10 months ago

@gvergnaud are you open to PRs regarding this? Ref #38 Chaining, .exhaustiveOtherwise or some other API would be very useful at times.

Like oguimbal said:

I want the types to break. Not my app.

LavransBjerkestrand commented 10 months ago

Could use a try catch but I prefer not to. Here is a workaround (kind of)

import { P, match } from 'ts-pattern'

type Status = 'active' | 'inactive' | 'pending'

const status = 'active' as Status

match(status)
  .with('active', () => '...')
  .with('inactive', () => '...')
  // .with('pending', () => '...')
  // ^ uncomment resolve NonExhaustiveError
  .with(P.not({}), () => 'fallback')
  .exhaustive()
// ~~~~~~~~~~ This expression is not callable.
//              Type 'NonExhaustiveError<"pending">' has no call signatures.
gustavopch commented 1 month ago

Taking inspiration from Effect's orElseAbsurd, I propose this solution:

declare const unit: string

match(unit)
  .with('days', () => 'd')
  .with('hours', () => 'h')
  .otherwiseAbsurd() // Equivalent to `.otherwise(() => { throw new Error('Absurd!') })`

I think it's much easier to grasp than the other options presented in this issue.