Testing equality against one member
samhh opened this issue ยท 4 comments
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:
- https://github.com/unsplash/unsplash-web/pull/6935/files/e1c1355bc39274f37433e73fdd67c65c6e7dd172#r702927554
- https://github.com/unsplash/unsplash-web/pull/6992#discussion_r714725718
- https://github.com/unsplash/unsplash-web/pull/7323#discussion_r760637234
If Eq
s 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 againstRain
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?
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.
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")