unsplash/sum-types

Nullary constructors are unsafe given function subtyping

Closed this issue · 2 comments

samhh commented

Consider how subtyping applies to function parameters. This for example typechecks:

declare const f: () => number
const g: (x: string) => number = f

It's desirable (subjectively) to write pointfree code. Where fp-ts and sum-types meet that might look like this:

O.match(nullaryConstructor, nullaryOrUnaryConstructor)

Unfortunately, because of an implementation detail in sum-types and function subtyping, nullaryOrUnaryConstructor is unsafe if it's nullary. Here's an example:

type Sum = Member<"Nullary">
const Sum = create<Sum>()

// Error as expected
const sum1 = Sum.mk.Nullary("foo")

// No error as expected because of function subtyping, however...
const withFoo = <A>(f: (x: string) => A): A => f("foo")
const sum2 = withFoo(Sum.mk.Nullary)

// Type of `v` is `null`, but the value is `"foo"`
const [_k, v] = serialize(sum2)

At the heart of sum-types' design is the use of proxies. This allows us to have the consumer define their type and not repeat themselves on the constructors until they need them at runtime, unlike some other libraries in this space.

The downside of this approach is that we know very little at runtime. This includes knowledge about whether or not a sum is nullary. We currently use arguments to figure out if a called constructor is nullary, and if so to supply null, which is needed at the point of serialisation.

Unfortunately, as per the above example, if a nullary constructor is called with some other value - say "foo" - then we'll think it's a non-nullary constructor and leave the string in the value position. This creates an unsafe mismatch between the types and the runtime values which is exposed when we serialise.


We need to know at runtime whether a constructor is nullary or not.

We could make all constructors take arguments explicitly, including null in the case of nullary constructors. This is simple and safe, but also quite ugly. Sum.mk.Nullary() becomes Sum.mk.Nullary(null).

We could leverage types to push consumers down two constructor objects, either mk or mkNullary (hopefully with a better name). With this approach we'd know at the point of construction which constructor object our constructor was called on, and provide a null as necessary in the latter case without ever checking arguments.

I don't know if there's some more exotic approach we could take in which nullary constructors aren't even functions. That'd be ideal but it's hard to envisage how that could work safely.

mkNullary (hopefully with a better name)

I don't mind this name. It makes it more obvious what we mean by "nullary", which is a word we use in a bunch of other places e.g. the io-ts bindings.

I guess this also means that decoding from the serialised form could fail, if the value is incorrect.