PureScript semantics for highly-overloaded API interfaces.
- Options with implicit defaults.
- Options with conversions - feels a lot like untagged unions.
- Options with
Maybelifting - feels a lot like nullable fields.
Say we have an API:
flub :: { foo :: Int, bar :: String, baz :: Maybe Boolean } -> StringThis API has very straightforwad and understandable options.
example = flub
{ foo: 42
, bar: "Hello"
, baz: Nothing
}But we find this inconvenient.
foohas an obvious default value.baris aString, but we also want to provide anInt.bazis oftenNothing, and we don't want to always have to wrap withJust.
That is, we'd like to call it in many different ways at our leisure:
flub { bar: "Hello" }
flub { bar: 99, baz: true }
flub { foo: 12, bar: "OK", baz: Just false }To start, we should separate out type declarations for defaulted (optional) fields and all fields.
type Optional =
( foo :: Int
, baz :: Maybe Boolean
)
type All =
( bar :: String
| Optional
)
defaultOptions :: { | Optional }
defaultOptions =
{ foo: 42
, baz: Nothing
}
flub :: { | All } -> StringIf all we want is defaulting, we can use a Defaults constraint.
flub
:: forall provided
. Defaults { | Optional } { | provided } { | All }
=> { | provided }
-> String
flub provided = ...
where
all :: { | All }
all = defaults defaultOptions providedThis will let us omit foo and baz:
flub { bar: "Hello" }
flub { foo: 99, bar: "Hello" }
flub { foo: 99, bar: "Hello", baz: Just true }However, we still must always wrap baz with Just, and we cannot provide
an Int for bar. To do that we must define ConvertOption instances.
To dispatch ConvertOption instances, we must define a new nominal data type
which we will use to index all the options of our function.
data Flub = FlubIt can just be a unit type, but it may be useful to add parameters for more dynamic configuration of conversions or to handle polymorphism.
Lets overload bar. We want it to take either an Int or a String.
instance convertFlubBar1 :: ConvertOption Flub "bar" Int String where
convertOption _ _ int = show int
instance convertFlubBar2 :: ConvertOptions Flub "bar" String String where
convertOption _ _ str = strThe first two arguments can generally be ignored. They are the Flub
constructor and Proxy "bar" respectively. These are used to dispatch the
instance.
An Int can be converted to a String via Show, and String can be given
an identity conversion.
Let's overload baz. We want to treat it more like a nullable field. This can
be accomplished with a conversion that lifts a value with Just.
instance convertFlubBaz1 :: ConvertOption Flub "baz" Boolean (Maybe Boolean) where
convertOption _ _ bool = Just bool
instance convertFlubBaz2 :: ConvertOption Flub "baz" (Maybe Boolean) (Maybe Boolean) where
convertOption _ _ mb = mbJust like bar, we've provided an identity conversion.
To extend our defaulting behavior with conversions, we should use
ConvertOptionsWithDefaults.
flub
:: forall provided
. ConvertOptionsWithDefaults Flub { | Optional } { | provided } { | All }
=> { | provided }
-> String
flub provided = ...
where
all :: { | All }
all = convertOptionsWithDefaults Flub defaultOptions providedAnd now we have our highly-overloaded API.
What happens if I don't write an identity conversion?
An identity conversion isn't strictly necessary, it just means you won't be able
to call the API with the "unconverted" type. This means for something like baz,
you could only express the absence of that option by omitting the field
altogether. This is rarely a good idea, since it means a user can't easily guard
the value on a condition.
example = flub
{ bar: "Hello"
, baz: guard shouldBaz *> Just true
}Instead they must write:
example =
if shouldBaz then
flub { bar: "Hello", baz: true }
else
flub { bar: "Hello" }Because it is not possible to express the absence of baz via Nothing.
Do I need to write an identity conversion for every option, or can there be a default?
You can express your conversions as an instance chain, with a default identity case at the end.
instance convertFlubBar :: ConvertOption Flub "bar" Int String where
convertOption _ _ int = show int
else instance convertFlubBaz :: ConvertOption Flub "baz" Boolean (Maybe Boolean) where
convertOption _ _ bool = Just bool
else instance convertFlubDefault :: ConvertOption Flub option a a where
convertOption _ _ = identityIn some cases, this can actually improve type inference. For example:
example = flub { bar: "Hello", baz: Nothing }Without the instance chain, this will result in an error since there is no
type annotation on Nothing. The compiler does not know that we want
Maybe Boolean rather than some other type. If we provide the identity
instance chain, then we get type-defaulting behavior, and this will typecheck
as Maybe Boolean.
However, one disadvantage of this approach is that users cannot extend your API with their own conversions. By avoiding instance chains, your set of options are extensible via normal typeclass machinery. That is, an end-user can overload your API with their own types after-the-fact to suit their convenience.
data Wat = Wat String
instance convertFlubWat :: ConvertOption Flub "bar" Wat String where
convertOption _ _ (Wat str) = strNow they can call your API with their new conversion.
If we wanted to extend the above API with a polymorphic option, we will need to make a couple of adjustments.
type Optional =
( foo :: Int
, baz :: Maybe Boolean
)
type All f =
( bar :: String
, poly :: f String
| Optional
)
-- The polymorphic type must be added to our data type.
data Flub (f :: Type -> Type) = Flub
flub
:: forall f provided
. ConvertOptionsWithDefaults (Flub f) { | Optional } { | provided } { | All f }
=> Functor f
=> { | provided }
-> String
flub provided = ...
where
all :: { | All f }
all = convertOptionsWithDefaults
(Flub :: Flub f) -- Our data type will need an annotation when called.
defaultOptions
provided
instance convertFlubBar :: ConvertOption (Flub f) "bar" Int String where
convertOption _ _ int = show int
else instance convertFlubBaz :: ConvertOption (Flub f) "baz" Boolean (Maybe Boolean) where
convertOption _ _ bool = Just bool
else instance convertFlubDefault :: ConvertOption (Flub f) option a a where
convertOption _ _ = identity