Stream: ideas

Topic: allow existential type vars in nominal types


view this post on Zulip Richard Feldman (Aug 02 2025 at 16:39):

this is a minimal idea to unlock some APIs that have historically been unimplementable in Roc.

the short version is that today this would give an error, and the idea is to stop having it be an error:

ConsList(elem) := [
    Nil,
    Cons(elem, ConsList(elem)),
    IdentityFn(a -> a),
]

view this post on Zulip Richard Feldman (Aug 02 2025 at 16:40):

the error would be that a doesn't appear in the type parameter list of ConsList

view this post on Zulip Richard Feldman (Aug 02 2025 at 16:41):

the idea would be to allow this, and to have it mean "a is local to IdentityFn's payload

view this post on Zulip Richard Feldman (Aug 02 2025 at 16:47):

this would also suggest that removing elem from ConsList would not give an error either:

ConsList := [
    Nil,
    Cons(elem, ConsList(elem)),
    IdentityFn(a -> a),
]

however, this would be a useless variant; supposing you wanted to make a Cons with some arbitrary payload in it, the type system could permit it, but you couldn't usefully pattern match on it - the type in the pattern match would always have to be an unbound type variable, no matter what you constructed it with originally

view this post on Zulip Richard Feldman (Aug 02 2025 at 16:48):

so I think in that scenario it would make sense to give a compile error anyway - and basically treat it so that you could only do this with functions inside nominal types (it couldn't work inside type aliases at all, because those have to expand to another type, and there would be no other way to express these relationships)

view this post on Zulip Richard Feldman (Aug 02 2025 at 16:48):

there are two concrete examples of APIs this would unlock

view this post on Zulip Richard Feldman (Aug 02 2025 at 16:48):

one is lazy from Elm's elm-html

view this post on Zulip Richard Feldman (Aug 02 2025 at 16:49):

e.g. lazy, lazy2, lazy3, ... here: https://package.elm-lang.org/packages/elm/html/latest/Html-Lazy#lazy

view this post on Zulip Richard Feldman (Aug 02 2025 at 17:11):

so to define the structure for this API you'd need something like:

Elem(event) := [
    Button(List(Attr(event)), List(Elem(event))),
    Text(Str),
    Lazy(a, (a -> Elem(event))),
    Lazy2(a, b, (a, b -> Elem(event))),
]

view this post on Zulip Richard Feldman (Aug 02 2025 at 17:12):

this API is not possible today because you'd have to put a, b, etc. in the type parameter list of Elem

view this post on Zulip Brendan Hansknecht (Aug 02 2025 at 17:13):

ConsList(elem) := [
    Nil,
    Cons(elem, ConsList(elem),
    IdentityFn(a -> a),
]

I don't quite follow. Doesn't this break the type system?

What would the below do? It is a type mismatch

id_x : I32 -> I32
id_x = \a -> a

x : ConsList(I32)
x = Conslist.IdentityFn(id_x)


id_y : U32 -> U32
id_y = \a -> a

y : ConsList(I32)
y = Conslist.IdentityFn(id_y)

# Based on this x and y have the same type, but that isn't true.
# They have different internal identity function types.
match((x,y)) {
    (ConsList.IdentityFn(x_fn), ConsList.IdentityFn(y_fn)) => {
        # x and y had the same type, so x_fn and y_fn must also have the same type.
        # So this should work, right?
        a = 123
        b = x_fn(a)
        c = y_fn(a)
        b == c # pretty sure this is a type mismatch
    }
   _ => ...
}

view this post on Zulip Brendan Hansknecht (Aug 02 2025 at 17:15):

I feel like I am missing something in your suggestion around binding

view this post on Zulip Richard Feldman (Aug 02 2025 at 17:16):

this would be a type mismatch:

y = Conslist.IdentityFn(id_y)

the reason I named it that is that it only accepts an identity function :smile:

view this post on Zulip Richard Feldman (Aug 02 2025 at 17:16):

it's a silly example, like I said

view this post on Zulip Richard Feldman (Aug 02 2025 at 17:17):

so you couldn't instantiate it with an I32 -> I32 function, because it expects an a -> a function

view this post on Zulip Brendan Hansknecht (Aug 02 2025 at 17:17):

Ah, you are suggesting forcing that a be unbound.

view this post on Zulip Richard Feldman (Aug 02 2025 at 17:17):

well it's bound, just only within that individual function

view this post on Zulip Richard Feldman (Aug 02 2025 at 17:18):

as in, the return type is bound to the arg type

view this post on Zulip Richard Feldman (Aug 02 2025 at 17:18):

technically the term is that it's existentially quantified within the function

view this post on Zulip Brendan Hansknecht (Aug 02 2025 at 17:20):

Yeah, I just misunderstood the binding suggestion here. I thought you were suggesting that we make:

ConsList(elem) := [
    Nil,
    Cons(elem, ConsList(elem)),
    IdentityFn(a -> a),
]

equivalent to:

ConsList(elem, a) := [
    Nil,
    Cons(elem, ConsList(elem)),
    IdentityFn(a -> a),
]

And just silently bind a to ConsList level variable.

view this post on Zulip Richard Feldman (Aug 02 2025 at 17:20):

a more recent (but perhaps less obvious than lazy) example is Stream.pipe from #ideas > hello world snippet friendliness

Stream(in, out, err) := [
    Simple(in => Result((unconsumed_in: in, List(out)), err)),
    Piped(Stream(in, old_out, err), Stream(old_out, out, err), in, old_out),
]

here, the key is that streams need to be able to store a buffer of unconsumed data when piping from one to another (e.g. you have a gzip header, you got some bytes from a file read or network stream, but not enough bytes to fully parse the header, so you buffer those bytes until you finally get enough bytes from upstream to parse it)

(actually this example makes me think maybe the rule is more complicated than just "there must be a function in the type" because here there's no visible function in the type, although there is one in the variants)

view this post on Zulip Richard Feldman (Aug 02 2025 at 17:20):

ahh gotcha

view this post on Zulip Brendan Hansknecht (Aug 02 2025 at 17:21):

So yeah, this makes sense. Does it need to be inside of a nominal tag? I assume not?

view this post on Zulip Richard Feldman (Aug 02 2025 at 17:22):

it needs to be inside a nominal type, at least

view this post on Zulip Richard Feldman (Aug 02 2025 at 17:22):

can't be a type alias

view this post on Zulip Richard Feldman (Aug 02 2025 at 17:23):

I believe this should be fine for type inference and monomorphization, although @Ayaz Hafiz would know better :smile:

view this post on Zulip Richard Feldman (Aug 02 2025 at 17:23):

there is a question of where you start to existentially quantify, and I think tags is the logical place to do it

you can also have explicit syntax for that, but I really dislike that approach - although I'm not sure exactly how to articulate why. I think partly because it makes the feature feel like a cool thing you should try to reach for, as opposed to a relaxation of errors. Like "this used to not work, but now it does."

view this post on Zulip Brendan Hansknecht (Aug 02 2025 at 17:23):

can't be a type alias

intriguing. Why?

view this post on Zulip Richard Feldman (Aug 02 2025 at 17:26):

short answer is that the way the implementation works is by creating new type variables automatically when you're constructing or pattern matching, and type aliases don't require that, so it's not clear when the compiler should/would do that

view this post on Zulip Ayaz Hafiz (Aug 02 2025 at 18:32):

this works fine but you would probably need to box the exisistentials. you can’t really specialize them

view this post on Zulip Richard Feldman (Aug 02 2025 at 18:35):

oh that's surprising - I thought we could do something similar to lambda sets for those, e.g. if we're storing foo, pretend we're storing {} -> foo and do the "store the biggest of the alternatives" thing

view this post on Zulip Richard Feldman (Aug 02 2025 at 22:32):

oh yeah @Ayaz Hafiz I'm correct that this wouldn't work with type aliases, right? Even with anonymous tag unions, bc of the cross-module issues you found

view this post on Zulip Ayaz Hafiz (Aug 03 2025 at 02:29):

i mean you could do that too i guess but look how hard that's been :)

view this post on Zulip Ayaz Hafiz (Aug 03 2025 at 02:29):

you can do it with type aliases, but its more complicated

view this post on Zulip Richard Feldman (Aug 03 2025 at 03:00):

interesting! more complicated how?

view this post on Zulip Austin Davis (Aug 03 2025 at 21:47):

Okay, so both Haskell and OCaml implement existential types, but they make different tradeoffs for different design choices. Here is my very rough understanding:

OCaml requires an explicit type parameter for existential types within both data types and function signatures. Those type variables can be quantified during unification, and the types can be erased at compile time. No runtime constructs necessary.

Haskell has a couple of language extensions for this: ExistentialQuantification and GADTs. I think the GADT version is what you are proposing here. In contrast to OCaml GADTs, Haskell uses dictionary-passing to provide type information at runtime, so you don't have to deal with explicit type variables at the top level of a type definition.

Funnily enough, in OCaml you can sort of manually implement Haskell's dictionary-passing abstraction in an explicit way by passing around first-class modules to your functions (with existential type variables declared in the function signature). This is how you emulate typeclasses in OCaml.

Anyway, I say all of that because I think it might be required to box the existential types if you want to eliminate the type variable from the top-level of the type declaration. But I'm hardly an expert on this stuff. Just interesting to see the prior art.

view this post on Zulip Richard Feldman (Aug 03 2025 at 22:05):

Anyway, I say all of that because I think it might be required to box
the existential types if you want to eliminate the type variable from
the top-level of the type declaration. But I'm hardly an expert on this
stuff. Just interesting to see the prior art.

yeah that's what we were talking about right before this in the conversation - basically it's the same situation as closures: the easiest implementation is to box them, but the "lambda set specialization" strategy we use for unboxed closures can be used here as well (so, avoiding allocations at the cost of compiler complexity)

view this post on Zulip Luke Boswell (Aug 03 2025 at 22:48):

@Austin Davis in case you haven't seen them before you may enjoy reading some of the RFCS https://github.com/roc-lang/rfcs Ayaz has written. :smiley:

view this post on Zulip Luke Boswell (Aug 03 2025 at 22:51):

Also checkout the PR https://github.com/roc-lang/rfcs/blob/ayaz/compile-with-lambda-sets/0102-compiling-lambda-sets.md

view this post on Zulip Austin Davis (Aug 06 2025 at 02:47):

@Luke Boswell Those RFCs are super good! Thanks for sharing! I have read them all, and I will definitely need to reread them to fully understand them, lol.


Last updated: Jun 16 2026 at 16:19 UTC