unsplash/sum-types

Testing equality against one member

samhh opened this issue ยท 4 comments

samhh commented

Motivational example:

type Weather = Sum.Member<'Sun'> | Sum.Member<'Rain', number>
const Weather = Sum.create<Weather>()

declare const x: Weather
declare const someCond: boolean

const shouldDoSomething1 = someCond && pipe(x, Weather.match({ Sun: constant(true), [Sum._]: constant(false) }))

const shouldDoSomething2 = someCond && Sum.getEq<Weather>({ Rain: Num.Eq }).equals(x, Weather.mk.Sun())

shouldDoSomething1:

  • ๐Ÿ”ด Verbose
  • ๐Ÿ”ด Needless laziness to produce a bool

shouldDoSomething2:

  • ๐Ÿ”ด Verbose
  • ๐Ÿ”ด Have to provide Eq instance for irrelevant member (we'll never internally use it, we'll check the tag first) - a big deal if we don't already have one and it's a complex type

How it looks in Haskell:

data Weather = Sun | Rain Int
  deriving Eq

x :: Weather
someCond :: Bool

shouldDoSomething1 = someCond && x == Sun

We still need to needlessly provide Eq (at the definition of Weather rather than the call site), however because of Haskell's typeclasses and its ability to derive it's far more ergonomic.


Other libraries often include an is function, but I think this misses the mark. What if Sun contained some very complex data and we wanted to test against Rain with a number? It only lets us match on the member key but not its data, so it'll look something like this in most libraries:

const shouldDoSomething3 = someCond && Sum.is.Rain(x) && Num.Eq.equals(x.value, 123)

I wonder if we could find a better balance here, something like (in fp-ts bindings):

// Overloaded so the interface changes if the constructor is or is not nullary.
// If it's nullary we don't take the callback.
const shouldDoSomething4 = someCond && Sum.is.Sun(x)
const shouldDoSomething5 = someCond && Sum.is.Rain(Num.Eq)(123)(x)
  • ๐ŸŸข Less verbose
  • ๐ŸŸข Doesn't require unneeded Eq instances
  • ๐Ÿ”ด Different interfaces might be unintuitive

Thoughts?

Linking to previous discussions for my own future reference:


If Eqs were automatically derived then I guess this wouldn't be an issue. But I'm not sure how we could do that for sum-types, because we're defining the union type at the level first. (For inspiration, the new Schema type in io-ts does this.) Maybe code generation would help.


What if Sun contained some very complex data and we wanted to test against Rain with a number? It only lets us match on the member key but not its data, so it'll look something like this in most libraries:

We have a Unionize helper called filterUnion that helps with this. Example usage re-appropriated for a sum-types union:

const shouldDoSomething1 = someCond && pipe(x, filterUnion(Weather)('Rain'), O.isSome)

const shouldDoSomething2 = pipe(x, filterUnion(Weather)('Rain'), O.exists(rain => rain === 123))

I'm sure there are downsides though. Would the implementation need to rely on implementation details of sum-types e.g. to access the tag and value?

samhh commented

I'm thinking this belongs in sum-types-io-ts. We're already providing the necessary information to decode an entire sum (any of many members), so there should be enough there to provide a helper for specific members. I'm not sure what the API would look like thinking of io-ts norms. cc @mlegenhausen

I like the idea of adding something like a filterUnion function because this would open up the possibility to add lenses to sum-types and it looks like the least opinionated approach. I think the best suited lens for sum-types would be a Prism which requires a getOption and reverseGet function. We already have mk for the reverseGet we would only need a equivalent getOption function. Maybe something like this

type Weather = Sum.Member<'Sun'> | Sum.Member<'Rain', number>
const Weather = Sum.create<Weather>()

declare const x: Weather

Weather.get.Rain(x) // Option<number>
Weather.get.Sun(x) // Option<void>

I think this is what we would need in the sum-types core and in an additional sum-types-monocle we could do something like this

const WeatherPrism = SumPrism.create<Weather>()
WeatherPrism.Rain // Prism<Weather, number>
WeatherPrism.Sun // Prism<Weather, void>

The problem with an io-ts solution is that when we would go in the direction of a decode function, we would always get decoded output which isn't what you want to compare to.

samhh commented

With a small backwards-compatible change to Sum.is we could define this, directly internally in a prospective monocle-ts bindings library and polymorphic:

// The same as `Sum.is`, but narrowing the return type to the specific member.
// This would be a backwards-compatible change.
declare const is: <A extends Sum.AnyMember>() => <B extends Tag<A>>(
  k: B,
) => (
  f: (mv: unknown) => mv is ValueByTag<A, B>,
) => (x: unknown) => x is Sum.Member<B, ValueByTag<A, B>>

export const getGet =
  <A extends Sum.AnyMember>() =>
  <B extends Tag<A>>(k: B): ((x: A) => Option<ValueByTag<A, B>>) =>
    flow(
      // `x` is already known to be `A`, so we don't need to validate the value.
      O.fromPredicate(is<A>()(k)((_): _ is ValueByTag<A, B> => true)),
      O.map(flow(Sum.serialize, snd)),
    )

type Weather = Sum.Member<"Sun"> | Sum.Member<"Rain", number>

const weatherGet = getGet<Weather>()

const getSun: (x: Weather) => O.Option<null> = weatherGet("Sun")
const getRain: (x: Weather) => O.Option<number> = weatherGet("Rain")