gvergnaud / ts-pattern

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

Support point-free style #209

Open kabo opened 11 months ago

kabo commented 11 months ago

Is your feature request related to a problem? Please describe. When doing functional programming it's common to things like pipes or promise chains. This ends up looking something like

functionReturningPromise()
  .then((res) => match(res).with(...).with(...).exhaustive())

or

import * as R from 'ramda'
const myFunc = R.pipe(
  R.pluck('thing'),
  R.find(R.propEq(true, 'a')),
  (item) => match(item).with(...).with(...).exhaustive()
)

See how one needs to create a function to get the input only to pass it into match?

Describe the solution you'd like It would be neat if there was a match that took the input last, so one could write like this

import { match } from 'ts-pattern/fp'
functionReturningPromise()
  .then(match.with(...).with(...).exhaustive())

or

import { matchFp } from 'ts-pattern'
import * as R from 'ramda'
const myFunc = R.pipe(
  R.pluck('thing'),
  R.find(R.propEq(true, 'a')),
  matchFp().with(...).with(...).exhaustive()
)

Or it could be done with different syntax, perhaps something like this

import { match } from 'ts-pattern/fp'
functionReturningPromise()
  .then(match({
    with: [
      [ pred, fn ],
      [ pred2, fn2 ],
    ],
    exhaustive: true,
  }))

This would make it much more intuitive to use in an FP / point-free context.

Describe alternatives you've considered One could perhaps get away with just writing some sort of wrapper around match.

gvergnaud commented 11 months ago

Hi!

Thanks for your proposal, I agree it would be neat for ts-pattern to support a point free version of match. The only problem is that I think it would be challenging to have good type inference if the input type isn't provided explicitly.

TS-Pattern uses the input type to infer:

Without the input type, there is no way to do either of these.

In your option 1 and 2, since the returned expression would have a call signature (it would be a function taking the input value), I think we could make TS forward the input type as a parameter to the resulting function, but we can't back-propagate it to previous .with calls, which means the only thing we could check is exhaustiveness.

In your option 3 (the one where with and exhaustive are passed as a data structure), I think we could provide the same level of inference as we have today. I'm not fond of having 2 different syntaxes for the same thing though. I think it would be pretty confusing so that's my least favorite option anyway.

Another option we could consider is this one:

import { match, with, exhaustive } from 'ts-pattern'
functionReturningPromise()
.then(
   match(
     with(...),
     with(...),
     exhaustive()
   )
)

Since the top level expression returns a function, we should be able to get the input type in this case. But with is a reserved keyword in JS, so we can't have a variable with that name.

Maybe something like:

import { match, input } from 'ts-pattern'
functionReturningPromise()
.then(
   match(
     input
       .with(...)
       .with(...)
       .exhaustive()
   )
)

All in all, this looks like a lot of work and a significant API extension only for a marginal DX improvement. I'm not sure this would be worth it, but I'd be happy to be convinced otherwise.

kabo commented 11 months ago

I started tinkering with the wrapper idea.

Code This does run, but doesn't seem to do much in terms of compile time checks/helping out. ```typescript import { match } from 'ts-pattern' interface ExhaustiveArg { readonly type: 'exhaustive' } interface RunArg { readonly type: 'run' } interface WithArg { readonly type: 'with' readonly pred: any readonly fn: any } interface WhenArg { readonly type: 'when' readonly pred: any readonly fn: any } interface OtherwiseArg { readonly type: 'otherwise' readonly fn: any } type MatchArg = WithArg | WhenArg type ReturnArg = ExhaustiveArg | OtherwiseArg | RunArg type MatchArgs = [ ...MatchArg[], ReturnArg ] const withFn = (pred, fn): WithArg => ({ type: 'with', pred, fn, }) const when = (pred, fn): WhenArg => ({ type: 'when', pred, fn, }) const otherwise = (fn): OtherwiseArg => ({ type: 'otherwise', fn, }) const exhaustive = (): ExhaustiveArg => ({ type: 'exhaustive' }) const run = (): RunArg => ({ type: 'run' }) const helper = (...args: MatchArgs) => (input: T) => args.reduce((matcher, arg) => arg.type === 'with' ? matcher.with(arg.pred, arg.fn) : arg.type === 'when' ? matcher.when(arg.pred, arg.fn) : arg.type === 'otherwise' ? matcher.otherwise(arg.fn) : arg.type === 'run' ? matcher.run() // @ts-expect-error not callable? : arg.type === 'exhaustive' ? matcher.exhaustive() : matcher , match(input)) console.log([{a: true}, {a: false}].map(helper( withFn({a: true}, () => 'a is true'), withFn({a: false}, () => 'a is false'), exhaustive() ))) ```

Not sure that's the right track though...

Perhaps take inspiration from another project like Effect? https://effect.website/docs/style/match Looks like one needs to provide the type explicitly and upfront. e.g. something like this?

import { match, input } from 'ts-pattern'
functionReturningPromise()
  .then(
     match(
       input<MyInputType>()
         .with(...)
         .with(...)
         .exhaustive()
     )
  )

I think needing to provide the input type is an acceptable tradeoff for not having to do (input) => match(input).... Would be nicer if it could infer it, but still an improvement.

Thoughts?

romainPrignon commented 8 months ago

the input solution "data-first" pipe API can solve the issue maybe ? there is an explanation here https://github.com/remeda/remeda