emmanueltouzery / prelude-ts

Functional programming, immutable collections and FP constructs for typescript and javascript
ISC License
377 stars 21 forks source link

Add .pluck method to Option #31

Closed mperktold closed 5 years ago

mperktold commented 5 years ago

In RxJS, there is a pluck operator for mapping items to nested properties, specified by strings.

I think this would nicely fit into Option, so we could write code like this:

interface Nested<T> {
  nested?: { value: T };
}

Option.some<Nested<string>>({ nested: { value: "x" } })
      .pluck("nested", "value")   // returns Option.some("x")

Option.some<Nested<string>>({ })
      .pluck("nested", "value")   // returns Option.none()

// Shouldn't compile since "invalid" is not a property of Nested
Option.some<Nested<string>>({ })
      .pluck("invalid")

Ideally, we should get compiler errors if pluck is passed a string that isn't a valid property of the wrapped type, like in the last example. See the overloads of pluck in RxJS for inspiration how this can be done.

emmanueltouzery commented 5 years ago

Hi, thank you for the suggestion!

In general, I see pluck more as a legacy mechanism, with several downsides, like: you won't get completion, nor refactoring, nor go to definition in an IDE, and conceptually you now have two functions (pluck and map) doing the same thing and cluttering the API. I didn't look at the RxJS types, but I also suspect they only support up to a certain number of parameters for that function, with overloads (if they want to have it type-safe).

ideally you would have:

interface Nested<T> {
  nested: Option<{ value: Option<T> }>;
}

In that case, you could do:

Option.some({nested: Option.of({value: Option.of("x")})
    .flatMap(x => x.nested)
    .flatMap(x => x.value)

With the original data structure, you'd have to do...

Option.some({nested: Option.of({value: Option.of("x")})
    .flatMap(x => Option.of(x.nested))
    .flatMap(x => Option.of(x.value))

Yes the pluck solution looks more terse, but I don't think the compromises are worth it. I also never saw any language which would accept multiple lambdas for map or flatMap. Of course, some languages support operators and that's the perfect solution for that problem, like Option.some "x" |> value |> nested, but we don't have those in JS...

Note that Option, like other prelude-ts data structures, offers an escape hatch, with transform. So if you'd like to have pluck but I refuse it, you can always do =>

function myOptionPluck<T, U>(opt: Option<T>, accessor1: keyof T, ...): Option<U> {
   ...
}

someOption.transform(myOptionPluck("nested", "value"))
mperktold commented 5 years ago

In general, I see pluck more as a legacy mechanism, with several downsides, like: you won't get completion, nor refactoring, nor go to definition in an IDE, and conceptually you now have two functions (pluck and map) doing the same thing and cluttering the API.

There is some support in Visual Studio Code for things like this, but you're right, it's limited. Your point regarding cluttering the API is also valid.

I didn't look at the RxJS types, but I also suspect they only support up to a certain number of parameters for that function, with overloads (if they want to have it type-safe).

Yes, that's exactly how it works.

ideally you would have:

interface Nested<T> {
  nested: Option<{ value: Option<T> }>;
}

In that case, you could do:

Option.some({nested: Option.of({value: Option.of("x")})
    .flatMap(x => x.nested)
    .flatMap(x => x.value)

With the original data structure, you'd have to do...

Option.some({nested: Option.of({value: Option.of("x")})
    .flatMap(x => Option.of(x.nested))
    .flatMap(x => Option.of(x.value))

In our project, we currently use our own Option (actually called Maybe) implementation for plucking nested properties out of JSON objects coming from a remote API, whose structure is described by an interface. Obviously, those objects cannot contain instances of Option, but your second proposal would work indeed.

Note that Option, like other prelude-ts data structures, offers an escape hatch, with transform. So if you'd like to have pluck but I refuse it, you can always do =>

function myOptionPluck<T, U>(opt: Option<T>, accessor1: keyof T, ...): Option<U> {
   ...
}

someOption.transform(myOptionPluck("nested", "value"))

Yes, this is a nice feature!

I also thought about something similar using a higher-order plucker function together with flatMap:

function plucker<T, K1 extends keyof T, K2 extends keyof T[K1]>
    (k1: K1, k2: K2): (obj: T) => Option<T[K1][K2]>
{
  ...
}

someOption.flatMap(plucker("nested", "value"))

I opened this issue because when I saw this project, I thought about replacing our own implementation with this, and then a pluck method similar to ours would be convenient.

But I agree that this is not needed in the Option API. It can be achieved with simple custom functions, which compose nicely with the existing API. 👌