ts-pattern icon indicating copy to clipboard operation
ts-pattern copied to clipboard

Allow otherwise to chain onto exhaustive

Open Liam-Tait opened this issue 2 years ago • 8 comments

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.

Liam-Tait avatar Feb 08 '23 02:02 Liam-Tait

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?

gvergnaud avatar Feb 19 '23 18:02 gvergnaud

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'

?

eboody avatar Feb 22 '23 13:02 eboody

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:

  • Use exhaustiveness checking to check all defined types
  • Handle when the type does not match any defined type or matches unknown
  • Refactored "easily" to the ideal exhaustive once confidence is higher in the types (validation, types are correct)

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

Liam-Tait avatar Feb 22 '23 22:02 Liam-Tait

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}`)
  })

XiNiHa avatar Mar 02 '23 02:03 XiNiHa

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.

eboody avatar Mar 02 '23 10:03 eboody

@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 avatar Nov 17 '23 20:11 LavransBjerkestrand

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.

LavransBjerkestrand avatar Nov 17 '23 20:11 LavransBjerkestrand

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.

gustavopch avatar Aug 11 '24 19:08 gustavopch