Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Nullary constructors are unsafe given function subtyping #44

Closed
samhh opened this issue Sep 5, 2022 · 2 comments · Fixed by #45
Closed

Nullary constructors are unsafe given function subtyping #44

samhh opened this issue Sep 5, 2022 · 2 comments · Fixed by #45

Comments

@samhh
Copy link
Member

samhh commented Sep 5, 2022

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.

@OliverJAsh
Copy link
Member

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.

@OliverJAsh
Copy link
Member

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants