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),
]
the error would be that a doesn't appear in the type parameter list of ConsList
the idea would be to allow this, and to have it mean "a is local to IdentityFn's payload
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
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)
there are two concrete examples of APIs this would unlock
one is lazy from Elm's elm-html
e.g. lazy, lazy2, lazy3, ... here: https://package.elm-lang.org/packages/elm/html/latest/Html-Lazy#lazy
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))),
]
this API is not possible today because you'd have to put a, b, etc. in the type parameter list of Elem
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
}
_ => ...
}
I feel like I am missing something in your suggestion around binding
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:
it's a silly example, like I said
so you couldn't instantiate it with an I32 -> I32 function, because it expects an a -> a function
Ah, you are suggesting forcing that a be unbound.
well it's bound, just only within that individual function
as in, the return type is bound to the arg type
technically the term is that it's existentially quantified within the function
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.
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)
ahh gotcha
So yeah, this makes sense. Does it need to be inside of a nominal tag? I assume not?
it needs to be inside a nominal type, at least
can't be a type alias
I believe this should be fine for type inference and monomorphization, although @Ayaz Hafiz would know better :smile:
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."
can't be a type alias
intriguing. Why?
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
this works fine but you would probably need to box the exisistentials. you can’t really specialize them
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
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
i mean you could do that too i guess but look how hard that's been :)
you can do it with type aliases, but its more complicated
interesting! more complicated how?
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.
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)
@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:
Also checkout the PR https://github.com/roc-lang/rfcs/blob/ayaz/compile-with-lambda-sets/0102-compiling-lambda-sets.md
@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