Stream: ideas

Topic: where clause changes


view this post on Zulip Luke Boswell (Jul 07 2025 at 01:32):

Richard Feldman said:

I made up some syntax that I found myself really wanting while doing this:

Num(num) : num where module(num).{
    abs : num -> num,
    abs_diff : num, num -> num,
    # ...many, many more of these
}

I've been looking at where clause and I think we may what to add braces to improve our error handling ability anyway.

Here are two examples.

# Missing colon in constraint
broken_fn1 : a -> b
  where
    a.method -> b

# Empty where clause
broken_fn2 : a -> b
  where

view this post on Zulip Luke Boswell (Jul 07 2025 at 01:33):

One question about this syntax... how would we handle clauses on different types.

process : a, b -> c
  where
    a.convert : a -> c,
    b.transform : b -> c,
process = ...

view this post on Zulip Luke Boswell (Jul 07 2025 at 01:39):

Maybe something like this?

process : List(a), b -> c where
    module(a).{ convert : List(a) -> c },
    module(b).{ transform : b -> c },

view this post on Zulip Richard Feldman (Jul 07 2025 at 01:52):

hm

view this post on Zulip Luke Boswell (Jul 07 2025 at 01:52):

Here's some more examples...

stringify : a -> Str where module(a).{ to_str : a -> Str }
stringify = |value| value.to_str()

process : a, b -> c where module(a).{
    a.convert : a -> c,
    b.transform : b -> c,
}
process = |_,_| ...

# can they be nested??
Hash(a) : a where
    module(a).{
        hash(hasher) -> hasher where module(hasher).{ Hasher },
    }

Decode(a) : a where
    module(a).{ decode(List(U8)) -> a }

Cache(k, v) := Dict(U64, v) where
    module(k).{
        k.hash : k -> U64,
    }

deserialize : List(U8) -> Result(a, [DecodeErr]) where
    module(a).{ decode : List(U8) -> Result(a, [DecodeErr]) }
deserialize = |_| ...

stringify : a -> Str where module(a).{ to_str : a -> Str }
stringify = |value| value.to_str()

view this post on Zulip Richard Feldman (Jul 07 2025 at 01:53):

one idea that would solve this, which I'd kinda been thinking about anyway, is to just always require the module(a) or module(b) style

view this post on Zulip Richard Feldman (Jul 07 2025 at 01:53):

so then every where is always followed by the keyword module

view this post on Zulip Richard Feldman (Jul 07 2025 at 01:53):

and if it's not, then you immediately have an error

view this post on Zulip Luke Boswell (Jul 07 2025 at 01:53):

That's how I wrote my examples in the gist https://gist.github.com/lukewilliamboswell/8aab3d62da2859b7dedbbe69fe0a895f

view this post on Zulip Richard Feldman (Jul 07 2025 at 01:56):

yeah the original idea was that you could either use that syntax or the where a.foo ... "method-style" syntax as a shorthand

view this post on Zulip Richard Feldman (Jul 07 2025 at 01:57):

but maybe the shorthand is not worth it, and it's better all around to only have one syntax for it

view this post on Zulip Luke Boswell (Jul 07 2025 at 01:57):

Any thoughts on this?

we may what to add braces to improve our error handling ability anyway.

view this post on Zulip Luke Boswell (Jul 07 2025 at 01:58):

I guess requiring the module solves this. If we see a newline then that keyword we know we're starting a new clause

view this post on Zulip Luke Boswell (Jul 07 2025 at 01:59):

If we see a where but not a module then that is also invalid. After the module is just a normal type annotation

view this post on Zulip Luke Boswell (Jul 07 2025 at 01:59):

module(a).<normal type annotation>

view this post on Zulip Richard Feldman (Jul 07 2025 at 02:04):

yeah exactly!

view this post on Zulip Anthony Bullard (Jul 07 2025 at 02:08):

i still wonder if there is a conflict with nested wheres

view this post on Zulip Anthony Bullard (Jul 07 2025 at 02:08):

around the comma delimiter

view this post on Zulip Richard Feldman (Jul 07 2025 at 02:08):

hm, what would be an example? :thinking:

view this post on Zulip Luke Boswell (Jul 07 2025 at 02:08):

Looks ok to me - but I'm not confident

Hash(a) : a
    where
        module(a).hash(hasher) -> hasher,
        module(hasher).Hasher,

view this post on Zulip Luke Boswell (Jul 07 2025 at 02:11):

Does that look ok? I may have butchered the example

view this post on Zulip Anthony Bullard (Jul 07 2025 at 02:12):

no nested

view this post on Zulip Luke Boswell (Jul 07 2025 at 02:12):

I'm sorry, I don't follow

view this post on Zulip Anthony Bullard (Jul 07 2025 at 02:14):

# can they be nested??
Hash(a) : a where
    module(a).hash(hasher) -> hasher where module(hasher).{ Hasher }, # is this for the nested where or the outer where?
  module(a).something_else(),

view this post on Zulip Anthony Bullard (Jul 07 2025 at 02:15):

(sorry on phone)

view this post on Zulip Luke Boswell (Jul 07 2025 at 02:16):

That was me rolling with the "idea" Richard proposed for new syntax.

I noticed your Hash example and tried to translate it to that format.

I think we've just decided to go with the non-shorthand syntax instead. So not using this record-style thing propose above.

view this post on Zulip Luke Boswell (Jul 07 2025 at 02:17):

So the current Hash snapshot example we have is

Hash(a) : a
    where
        a.hash(hasher) -> hasher,
        hasher.Hasher,

Then becomes the following if I understand the design correctly

Hash(a) : a
    where
        module(a).hash(hasher) -> hasher,
        module(hasher).Hasher,

view this post on Zulip Anthony Bullard (Jul 07 2025 at 02:20):

ok so no nesting

view this post on Zulip Luke Boswell (Jul 07 2025 at 02:22):

I'm just making these changes now in my Draft PR

view this post on Zulip Luke Boswell (Jul 07 2025 at 02:22):

Depending on how productive I am today, I hope to have Parsing and Can done for where clauses this afternoon

view this post on Zulip Anthony Bullard (Jul 07 2025 at 02:39):

are you going to rewrite the parsing of this feature?

view this post on Zulip Luke Boswell (Jul 07 2025 at 02:39):

Yes

view this post on Zulip Anthony Bullard (Jul 07 2025 at 02:39):

man my PR is getting out of date fast. hopefully i'll have time to actually work on it tomorrow or the day after

view this post on Zulip Luke Boswell (Jul 07 2025 at 03:32):

I don't understand what module(hasher).Hasher is specifically... I'm confused about what this means.

view this post on Zulip Luke Boswell (Jul 07 2025 at 03:34):

At first I thought ah that makes sense, in the module for the type hasher there should be a nominal type declaration Hasher. But now I'm just confused.

view this post on Zulip Luke Boswell (Jul 07 2025 at 03:42):

I found the design referenced in static dispatch proposal. Reading up on it now.

view this post on Zulip Luke Boswell (Jul 07 2025 at 03:50):

Here is a corrected explanation of from my research -- for anyone interested and reading this later.

# writing out the constraints manually, cumbersome and error prone
sort : List(elem) -> List(elem) where module(elem).order(elem, elem) -> [LT, EQ, GT]

The problem with this is that writing out the constraints every time can be combersome, particularly for larger sets of constraints.

So we also support an alias version where we can define an alias that includes constraints and then use these.

Sort(a) : a where  module(a).order(elem, elem) -> [LT, EQ, GT]

# much shorter than before
sort : List(elem) -> List(elem) where module(elem).Sort

view this post on Zulip Brendan Hansknecht (Jul 07 2025 at 04:04):

I feel like the syntax should be:

Sort(a) : a where module(a).order(elem, elem) -> [LT, EQ, GT]

# much shorter than before
sort : List(elem) -> List(elem) where Sort(elem)

view this post on Zulip Luke Boswell (Jul 07 2025 at 04:06):

I'm thinking that makes a whole lot of sense

view this post on Zulip Luke Boswell (Jul 07 2025 at 04:07):

This would bring us back to the parsing problem we had earlier though

view this post on Zulip Luke Boswell (Jul 07 2025 at 04:07):

now we need to support parsing lower and upper idents without a preceding module and so we don't know if we're onto a new statement or still in the where clause.

view this post on Zulip Brendan Hansknecht (Jul 07 2025 at 15:06):

Yeah, I feel like that is just a problem we need to fix some other way...cause module(elem).Hash just doesn't make sense, while Hash(elem) does. That or we need to define our interfaces differently. Cause they are the only place where we some reason break the Sort(elem) convention that could be sued anywhere else.

view this post on Zulip Brendan Hansknecht (Jul 07 2025 at 15:08):

Oh wait, does this work? Cause I think this fits normal roc expectations around type aliases and type variables.

Sortable(a) : a where module(a).order(elem, elem) -> [LT, EQ, GT]

# much shorter than before
sort : List(Sortable(elem)) -> List(elem)

view this post on Zulip Kiryl Dziamura (Jul 07 2025 at 15:09):

I read it like it was sortable and now it isn't

view this post on Zulip Brendan Hansknecht (Jul 07 2025 at 15:12):

I mean you could put sortable on both sides, I was just lazy

view this post on Zulip Brendan Hansknecht (Jul 07 2025 at 15:13):

But yeah, it does lead to duplication of application which might be less clear

view this post on Zulip Kiryl Dziamura (Jul 07 2025 at 17:26):

Btw, where without is or smth similar looks strange.

I mean List(a) where a is Sortable makes more sense than List(a) where Sortable(a). But I think it's not possible to have this.

List(a) implying Sortable(a), Hash(a) makes more sense for me. Like, there are constraints/requirements, not aliases, as it feels with where. I don't really like implying word, but you got the idea

view this post on Zulip Richard Feldman (Jul 07 2025 at 18:36):

sort : List(Sortable(elem)) -> List(Sortable(elem))

It's a bit long, but I do like how well this reads

view this post on Zulip Richard Feldman (Jul 07 2025 at 18:37):

"give me a list of sortable elements and I give you back another list of sortable elements"

view this post on Zulip Kiryl Dziamura (Jul 07 2025 at 19:27):

sort : List(Sortable(elem)) -> List(Sortable(elem))

Doesn't it mean

sort : List(a) -> List(b)

Where both a and b are sortable and not

sort : List(a) -> List(a)

Where a is sortable? I'm likely overthinking and it doesn't matter.

Upd. Structurally the are the same unless a and b are nominal but they aren't here

view this post on Zulip Richard Feldman (Jul 07 2025 at 20:47):

yeah any time you see a type variable like elem it should mean the same thing

view this post on Zulip Kiryl Dziamura (Jul 07 2025 at 21:18):

Just a note. The notation roc uses for the where clause in the thread reminds me prolog with its propositional logic (and I like it!):

Sortable(elem) : elem where module(elem).order(elem, elem) -> [LT, EQ, GT]

Sortable(elem) : elem is true if module(elem).order(elem, elem) -> [LT, EQ, GT] is true

sort : List(elem) -> List(elem) where Sortable(elem)

sort : List(elem) -> List(elem) is true if Sortable(elem) is true

view this post on Zulip Luke Boswell (Jul 07 2025 at 22:08):

To throw a bit of a spanner in here... In my PR I've currently got this example.

I hadn't picked up on the hasher typr var needs to also be included in the header when we were discussing earlier.

Hash(a, hasher) : a
    where
        module(a).hash : hasher -> hasher,
        module(hasher).Hasher,

view this post on Zulip Kiryl Dziamura (Jul 07 2025 at 22:53):

Won't the following work? I don't think Hash should be parametrized by hasher

Hasher(a) : a where
    module(a).add_bytes : b, U8 -> b,
    ...

-- first thought, but hashers from the second line aren't connected with hasher on the third line

Hashable(a) : a where
    module(a).hash : b, hasher -> hasher,
    Hasher(hasher)

-- it likely should be nested:

Hashable(a) : a where
    module(a).hash : (b, hasher -> hasher where
        Hasher(hasher))

-- we can move nested clause out of the scope

HashFn(a) : a, hasher -> hasher where
        Hasher(hasher)

Hashable(a) : a where
    module(a).hash : HashFn(b)

-- or super simple, the best here. but I think it don't think it will be enough in other places

Hashable(a) : a where
    module(a).hash : a, Hasher(hasher) -> Hasher(hasher)

view this post on Zulip Richard Feldman (Jul 07 2025 at 23:09):

yeah also let's try calling it Hashable

view this post on Zulip Richard Feldman (Jul 07 2025 at 23:09):

instead of Hash

view this post on Zulip Richard Feldman (Jul 07 2025 at 23:10):

I didn't want to use that terminology with Abilities bc I didn't want people to think everything had to become an Ability, but fortunately static dispatch doesn't have that problem bc it's just a type alias for the functions that are already there :smiley:

view this post on Zulip Kiryl Dziamura (Jul 07 2025 at 23:14):

Could you please review my assumptions in the message above? It looks like if we want to avoid nested clauses , the only way is to write some types separately (the HashFn in the example).

I also feel it's possible to stumble upon recursive types there

view this post on Zulip Luke Boswell (Jul 07 2025 at 23:19):

It's a bit mind bending for me. I'm trying to follow but my head is swimming with variations. I'll need to sit down and write out all my examples using the new syntax to feel comfortable with it. I have a few meetings back to back and won't be able to look at this in any detail for a few hours.

view this post on Zulip Kiryl Dziamura (Jul 08 2025 at 05:41):

Hm.. I'm confused. How scoping works in the where clause at all?

My assumption is that syntax A : B(a) -> B(a) means the following:

In the current scope, define type A, which is an alias to B (lookup in the current scope and outers) parametrized by the local type var a. With explicit scopes:

scopeA.A : scopeA.B(scopeC.a) -> scopeA.B(scopeC.a)

scopeC inside scopeB inside scopeA

I expect where clause follows the same logic.

Sortable(elem) : elem
    where
        module(elem).order : (elem, elem) -> [LT, EQ, GT]

Let's rewrite it using explicit scopes:

scopeA.Sortable(elem) : scopeB.elem
    where
        module(scopeB.elem).order : (scopeC.elem, scopeC.elem) -> [LT, EQ, GT]

What I'm concerned about is that scopeB.elem and scopeC.elem aren't the same. To align them, the following should work:

scopeA.Sortable(elem) : scopeB.elem
    where
        module(scopeB.elem).order : (scopeC.elem, scopeC.elem) -> [LT, EQ, GT] where scopeC.elem : scopeB.elem

But it's too verbose. I agree, this particular example is not ideal here because module can expose only functions that operate on the module's type. The same with Hashable:

scopeA.Hasher(scopeB.a) : scopeB.a where
    module(scopeB.a).add_bytes : scopeC.a, U8 -> scopeC.a where scopeC.a : scopeB.a,
    ...

scopeA.Hashable(scopeB.a) : scopeB.a where
    module(scopeB.a).hash : scopeC.a, Hasher(scopeC.hasher) -> Hasher(scopeC.hasher) where scopeC.a : scopeB.a

Maybe it's not that important in these particular cases, but I'm sure it's possible to find better examples.

The other assumption is that module(a) magically proposes its a to the inner scope. Then we don't need extra refinement indeed but it seems to be inconsistent with how scoping works.

view this post on Zulip Luke Boswell (Jul 08 2025 at 05:43):

My understanding is that this is one scope because it is one type annotation.

view this post on Zulip Luke Boswell (Jul 08 2025 at 05:44):

So the type var elem is referring to the same thing everywhere in this annotation

view this post on Zulip Brendan Hansknecht (Jul 08 2025 at 05:45):

Yeah, exactly, entire thing is one scope.

view this post on Zulip Brendan Hansknecht (Jul 08 2025 at 05:45):

At least for type variables

view this post on Zulip Brendan Hansknecht (Jul 08 2025 at 05:46):

Everything else like the B type can be referenced from the surrounding scope.

view this post on Zulip Luke Boswell (Jul 08 2025 at 05:47):

A : B(a) -> B(a)

I think this would need to be A(a) : B(a) -> B(a) because the type var is introduced by the alias.

Assuming B is available in the parent scope, and it has a single parameter type variable

view this post on Zulip Kiryl Dziamura (Jul 08 2025 at 05:48):

I see. So : inside of where clause reuses the scope. That makes sense

view this post on Zulip Brendan Hansknecht (Jul 08 2025 at 05:48):

So like:

scopeTopLevel.A : scopeTopLevel.B(scopeTypeDef.a) -> scopeTopLevel.B(scopeTypeDef.a)

scopeTopLevel.Sortable(scopeTypeDef.elem) : scopeTypeDef.elem
    where
        module(scopeTypeDef.elem).order : (scopeTypeDef.elem, scopeTypeDef.elem) -> [LT, EQ, GT]

view this post on Zulip Kiryl Dziamura (Jul 08 2025 at 05:59):

Luke Boswell said:

A : B(a) -> B(a)

I think this would need to be A(a) : B(a) -> B(a) because the type var is introduced by the alias.

Assuming B is available in the parent scope, and it has a single parameter type variable

The example in other words:

Concat : List(item), List(item) -> List(item)

concat : Concat

So Concat shouldn't be parametrized by item and item is var from the def scope

view this post on Zulip Brendan Hansknecht (Jul 08 2025 at 06:04):

I'm not sure that is valid. I think Concat(item) may be required....but not 100% sure either way.

view this post on Zulip Luke Boswell (Jul 08 2025 at 06:06):

Here's an example if I understand your proposed syntax from above @Kiryl Dziamura ... use the Sortable(...) alias in the type annotation instead of having a where clause

# Sortable.roc
Sortable(elem) : elem
    where
        module(elem).order : (elem, elem) -> [LT, EQ, GT],

# ImaginaryNumber.roc
ImaginaryNumber := { real: F64, imag: F64 }

order : ImaginaryNumber, ImaginaryNumber -> [LT, EQ, GT]
order = |_,_| -> ...

# App.roc
main : {} -> List(ImaginaryNumber)
main = |_| {
    a = ImaginaryNumber.{ real: 1.0, imag: 2.0 }
    b = ImaginaryNumber.{ real: 3.0, imag: 4.0 }

    sort_things([a,b]) # returns a sorted list of the imaginary numbers
}

sort_things : List(Sortable(things)) -> List(Sortable(things))
sort_things = |list| -> {
    # we have access to the List methods and also Sortable methods i.e. `order`
    # to implement this function...
    list.sort_with(|a,b| a.order(b))
}

view this post on Zulip Kiryl Dziamura (Jul 08 2025 at 06:24):

It was @Brendan Hansknecht finding, and the snippet looks valid

view this post on Zulip Luke Boswell (Jul 08 2025 at 06:28):

@Brendan Hansknecht can you give me a Hashable and Hasher in the above style? Just the aliases -- I'll try and build into a concrete example as above

view this post on Zulip Luke Boswell (Jul 08 2025 at 06:30):

What I'm loving about this syntax so far is how user friendly and intuitive it feels. The module(a).foo : ... syntax can be left until much later in the learning journey as an advanced topic. Beginners only see interface looking/sounding/feeling things like Sortable Inspectable etc

view this post on Zulip Brendan Hansknecht (Jul 08 2025 at 06:43):

this?

# Hash.roc
Hasher(h) : h
    where
        ## Adds a list of bytes to the hasher.
        module(h).add_bytes : h, List(U8) -> h

        ## Adds a single U8 to the hasher.
        module(h).add_u8 : h, U8 -> h

        ## Adds a single U16 to the hasher.
        module(h).add_u16 : h, U16 -> h

        ## Adds a single U32 to the hasher.
        module(h).add_u32 : h, U32 -> h

        ## Adds a single U64 to the hasher.
        module(h).add_u64 : h, U64 -> h

        ## Adds a single U128 to the hasher.
        module(h).add_u128 : h, U128 -> h

        ## Completes the hasher, extracting a hash value from its
        ## accumulated hash state.
        module(h).complete : h -> U64

Hashable(elem) : elem
    where
        module(elem).hash : elem, Hasher(h) -> Hasher(h)

view this post on Zulip Luke Boswell (Jul 08 2025 at 06:45):

The Hasher(h) is a problem right? wouldn't the h need to be in the declaration.. so Hashable(elem, h)

view this post on Zulip Brendan Hansknecht (Jul 08 2025 at 06:47):

Oh yeah....hmmm :thinking:

view this post on Zulip Kiryl Dziamura (Jul 08 2025 at 06:47):

I see no problem. Hashable shouldn't be parametrized by hasher, only the hash function should

view this post on Zulip Kiryl Dziamura (Jul 08 2025 at 06:48):

Similar logic with Concat from above

view this post on Zulip Luke Boswell (Jul 08 2025 at 06:57):

Here's how I imagine it might work with that definition

Hashable(elem, hasher) : elem
    where
        module(elem).hash : elem, Hasher(hasher) -> Hasher(hasher)

import Hashers exposing [MD5]

foo : Hashable(elem, MD5) -> U64
foo = |hashable_list| {
    var md5_hash_ = MD5.init({seed: 1234})

    md5_hash_ = item.hash(md5_hash_)

    md5_hash_.complete()
}

view this post on Zulip Kiryl Dziamura (Jul 08 2025 at 07:00):

Meaning you can use only MD5 to hash item?

view this post on Zulip Luke Boswell (Jul 08 2025 at 07:01):

Well in this function foo we have made the hasher type var concrete, 1 sec I'll give a generic example

view this post on Zulip Luke Boswell (Jul 08 2025 at 07:02):

bar : Hashable(elem, hasher), Hasher(hasher) -> U64
bar = |hashable_list, hasher| {
    item.hash(hasher).complete()
}

view this post on Zulip Kiryl Dziamura (Jul 08 2025 at 07:05):

Yeah, so elem and hasher are generic, right?

view this post on Zulip Kiryl Dziamura (Jul 08 2025 at 07:05):

I don't see how it differs from the hash function here: https://roc.zulipchat.com/#narrow/stream/304641-ideas/topic/where.20clause.20changes/near/527613907

view this post on Zulip Kiryl Dziamura (Jul 08 2025 at 07:07):

So h there is unified during application

view this post on Zulip Luke Boswell (Jul 08 2025 at 07:08):

And I think this would be how you implement the Hashable interface

# ImaginaryNumber.roc (implements the `Hashable` interface)
ImaginaryNumber := { real: F64, imag: F64 }

hash : ImaginaryNumber, Hasher(hasher) -> Hasher(hasher)
hash = |ImaginaryNumber.{real, imag}, var hasher_| -> {
    hasher_ = hasher_.add_u64(real.round_to_u64())
    hasher_ = hasher_.add_u64(imag.round_to_u64())
    hasher_
}

view this post on Zulip Kiryl Dziamura (Jul 08 2025 at 07:11):

Yep! So you can do this:

z : ComplexNumber

z_hash_md5 = z.hash(md5).complete()

-- or that:

z_hash_sha1 = z.hash(sha1).complete()

view this post on Zulip Luke Boswell (Jul 08 2025 at 07:14):

I don't think I've seen implicit type variables anywhere before in Roc's type system, I thought they were all explicit and introduced in the declaration header.

view this post on Zulip Luke Boswell (Jul 08 2025 at 07:14):

I don't think Concat : List(item), List(item) -> List(item) is permitted, it must be Concat(item) : List(item), List(item) -> List(item)

view this post on Zulip Kiryl Dziamura (Jul 08 2025 at 07:33):

But that's how function type definitions work. Like, from where the function takes the item variable value? It's free variable:

https://github.com/roc-lang/roc/blob/78a4a0f09b7b217fa250fb937a1036b0025339a8/crates/compiler/builtins/roc/List.roc#L402

concat : List(item), List(item) -> List(item)

But maybe aliases have different stylistic rules for the sake of explicitness. At least when I look at how Hash was implemented in rust compiler, I see it's not parametrized by hasher. Instead, the hash function has a free variable hasher:

https://github.com/roc-lang/roc/blob/78a4a0f09b7b217fa250fb937a1036b0025339a8/crates/compiler/builtins/roc/Hash.roc#L46

view this post on Zulip Luke Boswell (Jul 08 2025 at 07:46):

Thank you to Gemini 2.5. Pro for providing the following summary of the trade-offs between these two options.

Option 1: Parameterized Alias (Rank-1 Polymorphism)

This design introduces all type variables at the outermost scope of the alias definition, a structure formally known as a prenex polymorphic type.

Hashable(elem, hasher) : elem
    where
        module(elem).hash : elem, Hasher(hasher) -> Hasher(hasher)

Option 2: Non-Parameterized Alias (Higher-Rank Polymorphism)

This design quantifies the hasher type variable inside the structure of the alias, a feature known as higher-rank polymorphism.

Hashable(elem) : elem
    where
        module(elem).hash : elem, Hasher(h) -> Hasher(h)

view this post on Zulip Kiryl Dziamura (Jul 08 2025 at 07:58):

But higher-rank is not higher-kinded? So we can afford it

view this post on Zulip Richard Feldman (Jul 08 2025 at 11:03):

we can't do higher-rank

view this post on Zulip Richard Feldman (Jul 08 2025 at 11:09):

https://www.roc-lang.org/faq#higher-rank-types

view this post on Zulip Richard Feldman (Jul 08 2025 at 11:13):

that said, I'm not sure we would need higher-rank to make this work

view this post on Zulip Richard Feldman (Jul 08 2025 at 11:13):

with just one type variable

view this post on Zulip Richard Feldman (Jul 08 2025 at 11:26):

e g. Hash already has one type variable today with Abilities

view this post on Zulip Richard Feldman (Jul 08 2025 at 11:27):

it might require making each where have its own type variable scope and be unable to access variables in other scopes but I'm not sure

view this post on Zulip Kiryl Dziamura (Jul 08 2025 at 12:09):

Richard Feldman said:

e g. Hash already has one type variable today with Abilities

Sorry, looking at that, I'm not sure what do you mean

view this post on Zulip Richard Feldman (Jul 08 2025 at 12:13):

I mean the Ability itself, not the hash function it requires

view this post on Zulip Richard Feldman (Jul 08 2025 at 12:13):

the hash function takes 2 arguments and they need different type variables

view this post on Zulip Kiryl Dziamura (Jul 08 2025 at 12:18):

Yes, I don't see where the ability has a type variable at all besides functions. I must be missing something important

view this post on Zulip Richard Feldman (Jul 08 2025 at 12:43):

sorry, what I mean is that Abilities are not parameterized on multiple types

view this post on Zulip Richard Feldman (Jul 08 2025 at 12:44):

like today we say foo implements Hash - there's only one type variable involved in that, not 2

view this post on Zulip Richard Feldman (Jul 08 2025 at 12:44):

so I think it should be doable to maintain that when changing from Abilities to static dispatch

view this post on Zulip Kiryl Dziamura (Jul 08 2025 at 12:48):

https://roc.zulipchat.com/#narrow/stream/304641-ideas/topic/where.20clause.20changes/near/527613907

So this should work, right?

view this post on Zulip Richard Feldman (Jul 08 2025 at 13:01):

I think we can make it work, yeah

view this post on Zulip Kiryl Dziamura (Jul 08 2025 at 13:03):

Then we're on the same page. I just don't understand what is higher ranked polymorphism now. Because gemini says the Hashable(elem) example is what it is :smile:

view this post on Zulip Richard Feldman (Jul 08 2025 at 13:03):

conservatively, the rule could be that in module(a) : only the a can be from the outer annotation, and after the : you're not allowed to reuse any type variables from the rest of the annotation except a

view this post on Zulip Richard Feldman (Jul 08 2025 at 13:04):

we might be able to relax that rule but I'm not sure

view this post on Zulip Brendan Hansknecht (Jul 08 2025 at 15:16):

Yeah, I think what I missed before is that fucntions introduce their own type variables scope. As such, they can define new type variables

view this post on Zulip Brendan Hansknecht (Jul 08 2025 at 15:21):

So this is valid:

Hashable(elem) : elem
    where
        module(elem).hash : elem, Hasher(h) -> Hasher(h)

and would map to

scopeTopLevel.Hashable(scopeTypeDef.elem) : scopeTypeDef.elem
    where
        module(scopeTypeDef.elem).hash : scopeTypeDef.elem, scopeTopLevel.Hasher(scopeFunctionTypeDef.h) -> scopeTopLevel.Hasher(scopeFunctionTypeDef.h)

view this post on Zulip Brendan Hansknecht (Jul 08 2025 at 15:21):

This matches how current roc works with abilities

view this post on Zulip Brendan Hansknecht (Jul 08 2025 at 15:22):

This is also valid in roc:

BinaryFn: elem, elem -> elem

myFn: BinaryFn
myFn = \a, b -> b

view this post on Zulip Brendan Hansknecht (Jul 08 2025 at 15:23):

At least based on current roc rules these are all valid and I think they should remain valid

view this post on Zulip Brendan Hansknecht (Jul 08 2025 at 15:24):

That said, in the case of BinaryFn, it can actually be useful if we add a type variable:

BinaryFn(elem) : elem, elem -> elem

add: BinaryFn(Num(a))
myFn = \a, b -> a + b

view this post on Zulip Brendan Hansknecht (Jul 08 2025 at 15:28):

I don't think I've seen implicit type variables anywhere before in Roc's type system, I thought they were all explicit and introduced in the declaration header.

This is definitely not the case. Just look at the definition of Hash in Hash.roc.

Or run my first example with BinaryFn above in the roc repl. It works

view this post on Zulip Kiryl Dziamura (Jul 08 2025 at 17:03):

But it means HKT?

view this post on Zulip Kiryl Dziamura (Jul 08 2025 at 19:30):

Trying to come up with the similar ergonomics abilities have

Ability

Hash implements
    hash : elem, hasher -> hasher where elem implements Hash, hasher implements Hasher

Hasher implements
    add_bytes : a, List U8 -> a where a implements Hasher
    ...

Static dispatch

Hashable : module(elem) exports
    hash : elem, hasher -> hasher where hasher : Hasher

Hasher : module(hasher) exports
    add_bytes : hasher, U8 -> hasher,
    ...

Sortable : module(elem) exports
    order : (elem, elem) -> [LT, EQ, GT]

sort : List(elem) -> List(elem) where elem : Sortable

view this post on Zulip Brendan Hansknecht (Jul 08 2025 at 19:50):

Why would it mean HKT?

view this post on Zulip Brendan Hansknecht (Jul 08 2025 at 19:51):

I'm pretty sure it just is viewed as a new type variables from rocs perspective

view this post on Zulip Kiryl Dziamura (Jul 08 2025 at 19:57):

Ah yeah, it doesn't mean elem is parametrizable after all

view this post on Zulip Brendan Hansknecht (Jul 08 2025 at 20:28):

Let me give a better example that shows why it isn't a big deal:

BinaryFn: elem, elem -> elem

myFn: elem, BinaryFn -> elem
myFn = \a, _ -> a # This is the only possible implementation (aside from crash).
BinaryFn(elem): elem, elem -> elem

myFn: elem, BinaryFn(elem) -> elem
myFn = \a, fn -> fn(a, a) # This can have real implementations.

view this post on Zulip Brendan Hansknecht (Jul 08 2025 at 20:30):

And scoping of the type variables

BinaryFn: scopeInternal.elem, scopeInternal.elem -> scopeInternal.elem

myFn: scopeMyFn.elem, BinaryFn -> scopeMyFn.elem
BinaryFn(scopeExternal.elem): scopeExternal.elem, scopeExternal.elem -> scopeExternal.elem

myFn: scopeMyFn.elem, BinaryFn(scopeMyFn.elem) -> scopeMyFn.elem

view this post on Zulip Kiryl Dziamura (Jul 09 2025 at 06:51):

The downside is nested applications I think Sortable(Hashable(item))

view this post on Zulip Luke Boswell (Jul 09 2025 at 06:52):

Lol, make another alias ... Shortable(elem) : Sortable(Hashable(item)) :laughter_tears:

view this post on Zulip Brendan Hansknecht (Jul 09 2025 at 06:53):

Yeah, cause dict would be Hashable(Equatable(key))

view this post on Zulip Richard Feldman (Jul 09 2025 at 11:23):

seems fine to me

view this post on Zulip Richard Feldman (Jul 09 2025 at 11:23):

the main downside there is the long names, but no matter what syntax we use, you have to write out all of both names

view this post on Zulip Richard Feldman (Jul 09 2025 at 11:24):

nesting is both concise and obvious

view this post on Zulip Brendan Hansknecht (Jul 09 2025 at 20:13):

I think the biggest oddity of nesting is if a type variable shows up many times

view this post on Zulip Brendan Hansknecht (Jul 09 2025 at 20:14):

List(Equatable(elem)), List(Equatable(elem)) -> List(Equatable(elem))

Vs
List(Equatable(elem)), List(elem) -> List(elem)

view this post on Zulip Brendan Hansknecht (Jul 09 2025 at 20:15):

Is the second legal?

view this post on Zulip Brendan Hansknecht (Jul 09 2025 at 20:15):

If the first is required, then are we ok with potentially repeating the same thing many times instead of once?

view this post on Zulip Kiryl Dziamura (Jul 09 2025 at 20:18):

yeah, seems to be legal but very unpleasant (the second one). Equatable doesn't return a type but constraints what's passed inside. anecdotally it feels like mutation

view this post on Zulip Richard Feldman (Jul 09 2025 at 20:54):

the second one seems so confusing I think the compiler should complain about it

view this post on Zulip Kilian Vounckx (Jul 10 2025 at 06:15):

I prefer List(elem), List(elem) -> List(elem) where Equatable(elem) personally. It makes it clear that Equatable is something else than List. It also means we don't have to repeat the constraints. Just throwing it out there

view this post on Zulip Brendan Hansknecht (Jul 10 2025 at 06:18):

I agree with that

view this post on Zulip Brendan Hansknecht (Jul 10 2025 at 06:19):

the second one seems so confusing I think the compiler should complain about it

I'm pretty sure that I would always use the second one cause the first is too verbose.

view this post on Zulip Kilian Vounckx (Jul 10 2025 at 06:25):

Same. But even then, you'd have the choice to choose which elem to annotate. It just feels weird to me. I could get used to it probably, but I feel like any other language with type classes (traits interfaces protocols ...) does it with a where keyword or equivalent.

I understand static dispatch is not the same as all of the above, but in this case it is used in a pretty similar way

view this post on Zulip Brendan Hansknecht (Jul 10 2025 at 06:34):

Yeah, this is definitely my most preferred of the current options:
List(elem), List(elem) -> List(elem) where Equatable(elem)

view this post on Zulip Luke Boswell (Jul 10 2025 at 06:38):

The issue with that idea specifically is parsing... after the where how do we know we've stopped parsing where clauses and started parsing statements (Type Decls etc)

view this post on Zulip Kilian Vounckx (Jul 10 2025 at 06:49):

I'd say there are a few options then. I don't know which I prefer though.

Use braces (or similar):
Dict(key, value), key, value -> Dict(key, value) where { Hashable(key), Equatable(key) }

Use multiple where's:
Dict(key, value), key, value -> Dict(key, value) where Hashable(key) where Equatable(key)

Like the above but use an extra keyword, e.g. and:
Dict(key, value), key, value -> Dict(key, value) where Hashable(key) and Equatable(key)

view this post on Zulip Kilian Vounckx (Jul 10 2025 at 06:50):

Probably more options, but I'd use the ones above

view this post on Zulip Brendan Hansknecht (Jul 10 2025 at 06:53):

Can we just do this for multiple?:

Dict(key, value), key, value -> Dict(key, value) where { Hashable(key), Equatable(key) }

For single, the {} could even be optional if we want.

view this post on Zulip Luke Boswell (Jul 10 2025 at 06:55):

I think braces work, but I think they would need to be mandatory.

They could easily support both single and multi line formatting with a trailing comma

view this post on Zulip Brendan Hansknecht (Jul 10 2025 at 06:58):

That is fine by me and yeah trailing comma sounds great

view this post on Zulip Kiryl Dziamura (Jul 10 2025 at 07:54):

I don't like how Hashable(elem) : elem where module(elem) ... "infects" elem instead of describing it. It introduces specific non intuitive rule of where Hashable must be (on the right hand of where). imo it shouldn't be parametric

view this post on Zulip Kiryl Dziamura (Jul 10 2025 at 08:13):

To put it simply, just looking at Hashable(elem) and BinFn(elem) - what's the difference between them?

view this post on Zulip Brendan Hansknecht (Jul 10 2025 at 08:33):

It is also more complex than just Hashable for example, what of Dict. Should Dict encode the constraints or do they need to be specified in each function that uses a Dict?

Dict(k, v) := ... where { Hashable(k), Equatable(v) }

get : Dict(k, v), k -> v # does this need a where clause. Technically it is specifically in Dict.

view this post on Zulip Richard Feldman (Jul 10 2025 at 12:36):

definitely on the functions I think

view this post on Zulip Richard Feldman (Jul 10 2025 at 12:36):

you need them on the functions at a minimum

view this post on Zulip Richard Feldman (Jul 10 2025 at 12:37):

so might as well have that be the only place you have to write them

view this post on Zulip Anthony Bullard (Jul 10 2025 at 14:12):

So my big takeaway from this convo are the below:

My open questions:

view this post on Zulip Anthony Bullard (Jul 10 2025 at 14:13):

Are my above takeaways correct?

view this post on Zulip Richard Feldman (Jul 10 2025 at 14:15):

I don't think we concluded that they should always have braces

view this post on Zulip Brendan Hansknecht (Jul 10 2025 at 14:48):

Richard Feldman said:

so might as well have that be the only place you have to write them

I mean, write it on functions n times, or write it on the dict type once and be done forever. Writing it on the dict type sounds nicer....but it does mean folks have to be familiar with dict for that to work....cause you don't know the dict type, you would never see the constraint. At the same time, you would learn after calling a function when we print and error pointing to the dict source

view this post on Zulip Brendan Hansknecht (Jul 10 2025 at 14:50):

Of note, with abilities, you just have to define it once on dict and the never again on functions (at least the exposed ones that use dict in the signature)

view this post on Zulip Richard Feldman (Jul 10 2025 at 14:55):

I don't think this type is acceptable:

Dict.single : key, val -> Dict(key, val)

the return type is secretly constraining one of the arguments and you'll get a type mismatch if you give it a key that's not hashable and equatable even though it's a plain type variable

view this post on Zulip Richard Feldman (Jul 10 2025 at 14:55):

I don't think that design is ok

view this post on Zulip Richard Feldman (Jul 10 2025 at 14:56):

that's what I mean by the functions requiring it as a minimum

view this post on Zulip Richard Feldman (Jul 10 2025 at 15:03):

I think we should not sacrifice the design invariant that all constrains on type variables are visible in the type itself

view this post on Zulip Brendan Hansknecht (Jul 10 2025 at 15:06):

The type does work today in roc. Totally understand if we want to block it.

Personally I don't see it as an issue cause I think it is trivial to make a good error message for the case that explains everything.

view this post on Zulip Richard Feldman (Jul 10 2025 at 15:16):

yeah I don't think types should hide that info

view this post on Zulip Brendan Hansknecht (Jul 10 2025 at 15:17):

Ok

view this post on Zulip Richard Feldman (Jul 10 2025 at 15:17):

you shouldn't be required to run a type through the type checker to find out whether a variable is constrained; that's something I really think you should be able to confidently know just by looking at it

view this post on Zulip Brendan Hansknecht (Jul 10 2025 at 15:18):

I feel like this suggests we should have a different syntax for defining static dispatch interfaces and forcing them to only be used in where clauses.

view this post on Zulip Richard Feldman (Jul 10 2025 at 15:19):

hm, why? :thinking:

view this post on Zulip Richard Feldman (Jul 10 2025 at 15:19):

or maybe a better question is: what would it look like?

view this post on Zulip Brendan Hansknecht (Jul 10 2025 at 15:19):

Cause Dict(Hashable(k), v), Hashable(k) -> v

Is not clearly adding static dispatch constraints unless you know the definition of Hashable.

view this post on Zulip Richard Feldman (Jul 10 2025 at 15:20):

good point!

view this post on Zulip Brendan Hansknecht (Jul 10 2025 at 15:20):

As pointed out earlier, Hashable could be a normal type like List.

view this post on Zulip Richard Feldman (Jul 10 2025 at 15:20):

I think this conversation has convinced me we shouldn't do that syntax

view this post on Zulip Richard Feldman (Jul 10 2025 at 15:21):

and should instead put shorthands for where only in the where section

view this post on Zulip Anthony Bullard (Jul 10 2025 at 15:22):

i agree

view this post on Zulip Anthony Bullard (Jul 10 2025 at 15:23):

Richard Feldman said:

I don't think we concluded that they should always have braces

the whole genesis of this conversation is that the current syntax is pretty undecidable from a parsing standpoint, requiring braces solves that

view this post on Zulip Richard Feldman (Jul 10 2025 at 15:23):

right, but I don't think we settled on that as the best solution, just one of the options :smile:

view this post on Zulip Anthony Bullard (Jul 10 2025 at 15:23):

unless you special case where clauses and don't allow a trailing comma but the error case there sucks

view this post on Zulip Richard Feldman (Jul 10 2025 at 15:27):

e.g. we could say if you want more than one you need braces, but for one you don't

view this post on Zulip Richard Feldman (Jul 10 2025 at 15:29):

I don't think there was a problem with that design, was there?

view this post on Zulip Anthony Bullard (Jul 10 2025 at 15:30):

That could work, but that's a weird inconsistency to me at least

view this post on Zulip Anthony Bullard (Jul 10 2025 at 15:30):

Like it doesn't have any symmetry with anything else in the language

view this post on Zulip Kiryl Dziamura (Jul 10 2025 at 16:13):

Binopable(fn) : fn where fn : elem, elem -> elem

Sorry :smile:

view this post on Zulip Richard Feldman (Jul 10 2025 at 16:40):

the thing is, I think having exactly 1 will be by far the most common thing to see in error messages

view this post on Zulip Richard Feldman (Jul 10 2025 at 16:41):

separately, I think square brackets would make more sense bc these are neither key/value pairs like records nor statements like blocks, they're just a list of constraints

view this post on Zulip Kiryl Dziamura (Jul 10 2025 at 18:41):

I just realized what's the source of my confusion. There's no differentiation between Constraint(module) and TypeAlias(elem). Constraints make sense only for modules. I know we likely don't want to introduce new keywords (I used interface and self for the draft), but it might be a good starting point for discussion.

The idea is that interface describes only modules. And then only interfaces are allowed in the where clause

interface Hashable :
    hash : self, hasher -> hasher where hasher : [Hasher]

interface Hasher :
    add_bytes : self, U8 -> self
    ...


interface Sortable :
    order : (self, self) -> [LT, EQ, GT]

sort : List(elem) -> List(elem) where elem : [Sortable]


# Dict

interface Key : [Hashable, Equatable]

Dict(k, v) := ... where k : [Key]

get : Dict(k, v), k -> v where k : [Key]

view this post on Zulip Richard Feldman (Jul 10 2025 at 20:01):

I think the first thing we need to figure out with this syntax question is:

what does it look like when you have a mix of shothands and non-shorthands constraining the same type variable?

view this post on Zulip Kiryl Dziamura (Jul 10 2025 at 20:46):

Could you please explain me what is shorthand here and what is not?

view this post on Zulip Kiryl Dziamura (Jul 10 2025 at 20:55):

Like, can type aliases live in the same place with constraints?

view this post on Zulip Richard Feldman (Jul 10 2025 at 21:18):

shorthand as in like Hashable - whatever we want to call the thing where you don't write out the entire constraint and use a capitalized name instead

view this post on Zulip Richard Feldman (Jul 10 2025 at 21:18):

(I'm saying "shorthand" because I don't think it's clear what we want the final name for these things to be yet)

view this post on Zulip Kiryl Dziamura (Jul 10 2025 at 21:39):

Smth like that? No where clause tho it's possible to add it. Constraints are placed into the type parameters and allowed only there (or in the hypothetical where

interface Hashable : {
    hash(hasher : Hasher) : (self, hasher) -> hasher
}

interface Sortable : {
    order : (self, self) -> [LT, EQ, GT]
}

sort(elem : Sortable) : List(elem) -> List(elem)

# Dict

interface Key : Hashable + Equatable

Dict(k : Key, v) := ...

get(k : Key) : Dict(k, v), k -> v

# mixed

get(
    k : Equatable + {
        hash(hasher : Hasher) : (self, hasher) -> hasher
    }
) : Dict(k, v), k -> v

view this post on Zulip Richard Feldman (Jul 10 2025 at 21:56):

yeah that last example, # mixed is the thing I think we need to figure out first

view this post on Zulip Richard Feldman (Jul 10 2025 at 21:56):

like what's a nice syntax for that?

view this post on Zulip Richard Feldman (Jul 10 2025 at 21:58):

I think figuring out syntax for other scenarios (e.g. how to declare these things) is blocked on figuring out what that scenario looks like

view this post on Zulip Kiryl Dziamura (Jul 10 2025 at 22:01):

In my example self word and curly brackets make the interface definition universal: you can copy the whole definition and inline with no problems. Like, you don't need to rename anything inside of the curly brackets. Or extraction from inline is straightforward as well

view this post on Zulip Kiryl Dziamura (Jul 10 2025 at 22:03):

If + looks weird, it can piggyback record extention syntax

view this post on Zulip Richard Feldman (Jul 10 2025 at 22:04):

I think we concluded earlier that all of these constraints need to go after the where keyword

view this post on Zulip Richard Feldman (Jul 10 2025 at 22:05):

and type variables need to stand alone in the main annotation body, nothing attached to them

view this post on Zulip Richard Feldman (Jul 10 2025 at 22:05):

oh I see, you're putting the constraints first instead of at the end

view this post on Zulip Richard Feldman (Jul 10 2025 at 22:06):

I absolutely cannot stand how Haskell does that and I don't want to do it in Roc :sweat_smile:

view this post on Zulip Richard Feldman (Jul 10 2025 at 22:06):

I think constraints go at the end, not at the beginning

view this post on Zulip Kiryl Dziamura (Jul 10 2025 at 22:07):

I actually was inspired by rust. And when is optional here. You can still add it to push unimportant info back

view this post on Zulip Richard Feldman (Jul 10 2025 at 22:08):

it's different in Rust bc the angle brackets tell you right away you're dealing with something else, but I separately don't want angle brackets in Roc

view this post on Zulip Richard Feldman (Jul 10 2025 at 22:09):

I think they work best at the end and we should find a syntax where putting them at the end is the only way to do it

view this post on Zulip Kiryl Dziamura (Jul 10 2025 at 22:09):

Np, let me come up with how it could look like with where keyword

view this post on Zulip Kiryl Dziamura (Jul 10 2025 at 22:13):

I already forgot how valid record extension looks like in roc, but you get the idea:

interface Hashable : {
    hash : (self, hasher) -> hasher where (hasher : Hasher)
}

interface Sortable : {
    order : (self, self) -> [LT, EQ, GT]
}

sort : List(elem) -> List(elem) where (elem : Sortable)

# Dict

interface Key : { Hashable & Equatable }

Dict(k, v) := ... where (key : Key)

get : Dict(k, v), k -> v where (k : Key)

# mixed

get : Dict(k, v), k -> v where (k : {
    Equatable &
    hash : (self, hasher) -> hasher where (hasher : Hasher)
})

view this post on Zulip Richard Feldman (Jul 10 2025 at 22:21):

we should also include in the example decoding, because that's where the type variable is in the function's return type rather than its first argument :smile:

view this post on Zulip Kiryl Dziamura (Jul 10 2025 at 22:21):

Could you share a snippet in concern?

view this post on Zulip Jared Ramirez (Jul 10 2025 at 22:29):

Fwiw, in haskell it's discouraged to put constraints in types like:

Dict(k, v) := ... where (key : Key)

As it can end up causing you to have to include a reference to the contraint in annoying ways.

For example:

size : Dict(k, v), k -> v where (k : Key)
size = ...

It's kinda annoying that we have to include where (k : Key) here, because size very likely doesn't need anything Key (or Hashable/Equatable) related to do it's work. But the type system would force the user to include where (k : Key), because Dict's def says "whatever k is, make sure it's a Key"

view this post on Zulip Kiryl Dziamura (Jul 10 2025 at 22:34):

Shouldn't this work?

size : Dict(_, _) -> U32

Or what is size?

view this post on Zulip Kiryl Dziamura (Jul 10 2025 at 22:46):

@Richard Feldman you meant this?

# ability
Decoder val fmt := List U8, fmt -> DecodeResult val where fmt implements DecoderFormatting

Decoding implements
    decoder : Decoder val fmt where val implements Decoding, fmt implements DecoderFormatting

DecodeFormatting implements ...

# proposal

Decoder(val, fmt) := List(U8), fmt -> DecoderResult(val) where (fmt : DecodeFormatting)

interface Decoding : {
    decoder : Decoder(self, fmt) where (fmt : DecoderFormatting)
}

interface DecodeFormatting : ...

# inlined tho without the opaque type

interface Decoding : {
    decoder : List(U8), fmt -> DecoderResult(self) where (fmt : DecodeFormatting)
}

interface DecodeFormatting : ...

view this post on Zulip Richard Feldman (Jul 10 2025 at 22:47):

Kiryl Dziamura said:

Could you share a snippet in concern?

I'm on mobile and can't find the original thread but it's basically:

Decode.decode : List(U8), DecodeFmt -> Result(value, DecodeErr)
    where module(value).decode : ... -> Result(value, DecodeErr)

the key is that we need to do static dispatch on the type variable value but it only appears in the return position
the key is that we need to be

view this post on Zulip Richard Feldman (Jul 10 2025 at 22:48):

that's why we started using the module(a) syntax

view this post on Zulip Kiryl Dziamura (Jul 10 2025 at 22:49):

From mobile too :smile:I hope my last message is relevant

https://roc.zulipchat.com/#narrow/stream/304641-ideas/topic/where.20clause.20changes/near/528169564

view this post on Zulip Kiryl Dziamura (Jul 10 2025 at 22:50):

The interface (meaning "module interface") poposal also can keep Eq and Sort instead of having -able suffix

view this post on Zulip Kiryl Dziamura (Jul 10 2025 at 23:37):

self there is the type which module interface describes. It's a special keyword if anything. I don't want to make interfaces parametric since it introduces confusion: they work only for a specific thing, to describe required module spec. Theay aren't really parametric (in the same way as abilities are). self can be removed but it would remove a lot of flexibility from the approach

view this post on Zulip Kiryl Dziamura (Jul 10 2025 at 23:42):

So self word allows to refer to the current interface, so it's possible to move it from interface to interface and inline with no extra work. With abilities it wasn't possible because it required to specify which type var in the method implements the describing interface

view this post on Zulip Kiryl Dziamura (Jul 10 2025 at 23:45):

self also allows immediately check if the interface described correctly because each method of a module should contain at least one self in its definition

view this post on Zulip Kiryl Dziamura (Jul 10 2025 at 23:51):

If your interface requires another interface to be implemented on the module:

interface Print : {
    Fmt &
    print! : self -> Task,
}

Print requires Fmt to be implemented because .print! relies on .format from there. So self has both fmt and print!. No need to write where self implements { Fmt & Print }

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 00:02):

The interface syntax also works well with platform.requires. It already has the same style for describing the required interface:

platform "cli"
    requires {} { main! : List Arg.Arg => Result {} [Exit I32 Str]_ }
    ...

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 00:12):

If we now imagine that roc application is a module that describes type App, then in the platform.require self will be the state of the app. Like Model in the basic-webserver

# current roc

platform "webserver"
    requires { Model } {
        init! : {} => Result Model [Exit I32 Str]_,
        respond! : Http.Request, Model => Result Http.Response [ServerErr Str]_,
    }

# proposal

platform "webserver"
    requires {
        init! : {} => Result self [Exit I32 Str]_,
        respond! : Http.Request, self => Result Http.Response [ServerErr Str]_,
    }

application

# now
app [Model, init!, respond!] { pf: platform "../platform/main.roc" }

# proposal
app Model { pf: platform "../platform/main.roc" }

view this post on Zulip Luke Boswell (Jul 11 2025 at 00:17):

Here's is a new proposal https://gist.github.com/lukewilliamboswell/c25716f0e9868462b933af242df3c980

I've tried to synthesis ideas from this thread into a single coherent design.

view this post on Zulip Luke Boswell (Jul 11 2025 at 00:18):

I'm introducing :: as a new "interface declaration"

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 00:19):

I'll create a gist with my proposal in nearest days then with motivations behind every point

view this post on Zulip Richard Feldman (Jul 11 2025 at 00:27):

Richard Feldman said:

I think figuring out syntax for other scenarios (e.g. how to declare these things) is blocked on figuring out what that scenario looks like

@Luke Boswell do you have an example of this one?

view this post on Zulip Richard Feldman (Jul 11 2025 at 00:29):

one thing I think is settled and does not need to be revisited is that this is the syntax for calling them:

from_json = |bytes, json_decoder| {
    module(value).decode(bytes)
}

view this post on Zulip Richard Feldman (Jul 11 2025 at 00:30):

given that, I think the most obvious syntax for the inferred type of this function is to have symmetry with the calling syntax, e.g. the type should also have module(value) in it so you can easily see how they connect

view this post on Zulip Luke Boswell (Jul 11 2025 at 00:30):

Dict(k,v) := ... where { k : [Equatable, Hashable] }

get : Dict(k,v), k -> v
get = |dict, key| ...

I think I'm missing something here, because this seems like a simple case to me.

view this post on Zulip Richard Feldman (Jul 11 2025 at 00:30):

yes, that's the problem :smile:

view this post on Zulip Richard Feldman (Jul 11 2025 at 00:31):

1 sec

view this post on Zulip Richard Feldman (Jul 11 2025 at 00:31):

suppose I put this into roc repl:

|dict, key, val| {
    module(dict).insert(key, val)
}

view this post on Zulip Richard Feldman (Jul 11 2025 at 00:31):

roc repl must print out an inferred type for that function

view this post on Zulip Richard Feldman (Jul 11 2025 at 00:32):

so we obviously need an "anonymous constraint" syntax that doesn't involve making up names for things

for example, putting that lambda into roc repl might print something like:

dict, key, val -> ret
    where module(dict).insert : dict, key, val -> ret

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 00:33):

like self? ok, let me describe everything I have with the interface proposal in a separate doc

view this post on Zulip Richard Feldman (Jul 11 2025 at 00:33):

I strongly dislike magic type variable names like self

view this post on Zulip Richard Feldman (Jul 11 2025 at 00:33):

(the way it's used in Rust I mean)

view this post on Zulip Richard Feldman (Jul 11 2025 at 00:33):

or this in JavaScript

view this post on Zulip Richard Feldman (Jul 11 2025 at 00:34):

I don't think we should do any "if you choose exactly this type variable name, it still works like a type variable but it has magically different semantics" designs

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 00:35):

I'll use magic self and then we can figure out how it can be improved. wdyt?

view this post on Zulip Richard Feldman (Jul 11 2025 at 00:38):

Richard Feldman said:

suppose I put this into roc repl:

|dict, key, val| {
    module(dict).insert(key, val)
}

roc repl must print out an inferred type for that function

so we obviously need an "anonymous constraint" syntax that doesn't involve making up names for things

for example, putting that lambda into roc repl might print something like:

dict, key, val -> ret
    where module(dict).insert : dict, key, val -> ret

my point is that this "anonymous constraint syntax" is a hard requirement

view this post on Zulip Richard Feldman (Jul 11 2025 at 00:39):

if we want to have an optional additional "you give a name to a set of constraints" feature (like Equatable) it needs to exist as an optional addition to this anonymous constraint syntax

view this post on Zulip Richard Feldman (Jul 11 2025 at 00:39):

which also means it needs to be able to coexist with the anonymous syntax in the same type

view this post on Zulip Richard Feldman (Jul 11 2025 at 00:40):

so what I'm saying is that we are blocked on having an example of a type annotation that shows both anonymous and non-anonymous constraints coexisting in the same annotation

view this post on Zulip Richard Feldman (Jul 11 2025 at 00:40):

and it looks nice and is understandable etc.

view this post on Zulip Richard Feldman (Jul 11 2025 at 00:40):

I don't think any of the rest of the proposals are worth thinking about until we have that

view this post on Zulip Richard Feldman (Jul 11 2025 at 00:41):

so I think we should focus on showing examples of that "mixed anonymous and non-anonymous constraints in a single type" syntax until we find one that we like, and then we can work backwards from there to figure out how to define them etc.

view this post on Zulip Luke Boswell (Jul 11 2025 at 00:42):

I've taken the long way around... but I feel like I finally understand the problem we're trying to solve now. :smiley:

view this post on Zulip Luke Boswell (Jul 11 2025 at 00:44):

Ok, so as a baseline ... we know this works right? kind of silly, I'm basically just replacing the type vars with the anonymous versions

>> |dict, key, val| {dict.insert(key, val) }

a, b, c -> a where { module(a).insert : b, c -> a }

view this post on Zulip Richard Feldman (Jul 11 2025 at 00:45):

also, since I don't think it's been mentioned in this thread: the reason for the module(a) syntax in expressions is so that you can use type variables from your annotation in the parens, e.g.

decode : Arg -> ret_val
decode = |arg| {
    module(ret_val).foo(arg)
}

view this post on Zulip Richard Feldman (Jul 11 2025 at 00:45):

so here I'm saying "dispatch on the type variable from my annotation"

view this post on Zulip Richard Feldman (Jul 11 2025 at 00:46):

and actually since I've recently worked on canonicalization, I just realized we probably need to restrict that to only work on type variables, so the earlier example of module(dict).insert(key, val) would actually have needed to be dict.insert(key, val)

view this post on Zulip Richard Feldman (Jul 11 2025 at 00:47):

but with that modification, yes, the a, b, c -> a where { module(a).insert : b, c -> a } looks right to me :thumbs_up:

view this post on Zulip Luke Boswell (Jul 11 2025 at 00:56):

It's a bit of a mind bender.... please ignore the specific syntax (though I don't mind this one)... is this a correct set of constraints we could infer from this function in the REPL?

>> md5_hash = |list| {
>>     var md5_hasher_ = MD5.init({seed: 1234})
>>
>>     for item in list {
>>         md5_hasher_ = item.hash(md5_hasher_)
>>     }
>>
>>     md5_hasher_.complete()
>> }

md5_hash : List(a) -> b where [
  module(a).hash : a, c -> c,
  module(c).init : { seed: Num(d) } -> c,
  module(c).complete : c -> b,
]

(edit - sorry realised we don't know its a U64)

view this post on Zulip Richard Feldman (Jul 11 2025 at 01:02):

today, yes (setting aside #ideas > Do we need Num anymore?)

view this post on Zulip Richard Feldman (Jul 11 2025 at 01:03):

actually, sorry - I missed something

view this post on Zulip Richard Feldman (Jul 11 2025 at 01:03):

MD5.init is not static dispatch, that's just a plain old call to a function named init in the MD5 module

view this post on Zulip Richard Feldman (Jul 11 2025 at 01:04):

so it wouldn't be module(c).init : { seed: Num(d) } -> c

view this post on Zulip Richard Feldman (Jul 11 2025 at 01:04):

there just wouldn't be a constraint for that call bc it's not using static dispatch at all

view this post on Zulip Luke Boswell (Jul 11 2025 at 01:08):

Ok another one...

>> hash = |ImaginaryNumber.{real, imag}, var hasher_| -> {
>>     hasher_ = hasher_.add_u64(real.round_to_u64())
>>     hasher_ = hasher_.add_u64(imag.round_to_u64())
>>     hasher_
>> }

hash : ImaginaryNumber.{ real: a, imag: b }, c -> c where [
  module(a).round_to_u64 : a -> d,
  module(b).round_to_u64 : b -> d,
  module(b).add_u64 : c, d -> c,
]

view this post on Zulip Richard Feldman (Jul 11 2025 at 01:10):

what does ImaginaryNumber.{ real, imag } mean? :raised:

view this post on Zulip Luke Boswell (Jul 11 2025 at 01:11):

Thats a nominal record - being de-structured into it's fields real and imag

view this post on Zulip Luke Boswell (Jul 11 2025 at 01:11):

ImaginaryNumber := { real: F64, imag: F64 }

view this post on Zulip Richard Feldman (Jul 11 2025 at 01:11):

let's not bring those into this topic :joy:

view this post on Zulip Richard Feldman (Jul 11 2025 at 01:11):

we have enough new stuff as it is!

view this post on Zulip Richard Feldman (Jul 11 2025 at 01:11):

in this discussion

view this post on Zulip Luke Boswell (Jul 11 2025 at 01:12):

Ohk, I that was already accepted from the Custom Types proposal.

view this post on Zulip Richard Feldman (Jul 11 2025 at 01:13):

yes, but we ran into implementation challenges that we didn't realize, so let's just not bring it up here :sweat_smile:

view this post on Zulip Richard Feldman (Jul 11 2025 at 01:13):

anyway, here is an example of what I'm talking about as the thing we need to settle on:

Dict.insert : Dict(key,val), key, val -> Dict(key, val)
    where { module(key).{ ..Eq, ..Hash } }
Dict.insert : Dict(key,val), key, val -> Dict(key, val)
    where { module(key).{ eq : key, key -> Bool, ..Hash } }

view this post on Zulip Richard Feldman (Jul 11 2025 at 01:13):

that last example above is the thing I'm talking about

view this post on Zulip Richard Feldman (Jul 11 2025 at 01:14):

where { module(key).{ eq : key, key -> Bool, ..Hash } }

this has both an anonymous constraint (eq : key, key -> Bool) as well as a non-anonymous one (..Hash) coexisting in the same type annotation

view this post on Zulip Richard Feldman (Jul 11 2025 at 01:14):

we need to settle on a syntax (this exact syntax could be one option, for example) for that specific case

view this post on Zulip Richard Feldman (Jul 11 2025 at 01:14):

and until we do that, I don't think it's particularly useful to discuss any of the other aspects of this syntax

view this post on Zulip Richard Feldman (Jul 11 2025 at 01:15):

because the question for all of them will end up being "ok but how does it look when you mix anonymous and non-anonymous ones in the same type?" and then if it looks bad we'll end up throwing it out anyway

view this post on Zulip Richard Feldman (Jul 11 2025 at 01:15):

so we need to focus on finding a syntax we like for that use case, so that we don't waste a bunch of time on other aspects that will just end up getting tripped up when we inevitably get to the question of how it works in that mixed anonymous/non-anonymous case

view this post on Zulip Richard Feldman (Jul 11 2025 at 01:17):

I think it's pretty understandable what this part is doing:

module(key).{ eq : key, key -> Bool, ..Hash }

in that { a : b, c : d, ..foo } is already a thing for records, and it means "mix the foo record in with the rest of this record," so I think the intuition lines up pretty well with what we're trying to convey here

view this post on Zulip Richard Feldman (Jul 11 2025 at 01:18):

however, that ..Hash doesn't look the nicest when trying to write a fairly normal function:

Dict.insert : Dict(key, val), key, val -> Dict(key, val)
    where { module(key).{ ..Eq, ..Hash } }

that's a very symbol-heavy where, lots of dots and curly braces

view this post on Zulip Richard Feldman (Jul 11 2025 at 01:24):

pretty verbose too

view this post on Zulip Richard Feldman (Jul 11 2025 at 01:25):

so now if you go for maximum conciseness like:

Dict.insert : Dict(key, val), key, val -> Dict(key, val)
    where key : Eq + Hash

now this has no relation whatsoever to the anonymous syntax, we basically have 2 totally separate syntaxes for doing the same thing and they don't compose or fit together in any way

view this post on Zulip Richard Feldman (Jul 11 2025 at 01:35):

which, in fairness, might turn out to be the way to go

view this post on Zulip Richard Feldman (Jul 11 2025 at 01:36):

but if that's the case then we might just want to start with the verbose-but-maximally-flexible syntax, leave it as a known issue (that we may want to introduce sugar to fix later), and just see what actual type signatures we end up with in practice when we have some actual real-world module docs to look at

view this post on Zulip Richard Feldman (Jul 11 2025 at 01:36):

so we can see what the commonalities actually are and what sugar might look nicer with htem

view this post on Zulip Luke Boswell (Jul 11 2025 at 01:36):

Here is another proposal https://gist.github.com/lukewilliamboswell/e244ded8b798c715d5376f566c5d17c7

Using [] square brackets to contain the set of constraints.

view this post on Zulip Luke Boswell (Jul 11 2025 at 01:37):

Almost identical to what we currently have

view this post on Zulip Luke Boswell (Jul 11 2025 at 01:38):

The key.Hash syntax is a little jank, but it's concise and clear and I think we might get used to it pretty quickly.

view this post on Zulip Richard Feldman (Jul 11 2025 at 01:39):

what if you make both non-anonymous?

view this post on Zulip Richard Feldman (Jul 11 2025 at 01:39):

is it where [key.Eq, key.Hash]?

view this post on Zulip Luke Boswell (Jul 11 2025 at 01:40):

For comparison

Dict.insert : Dict(key, val), key, val -> Dict(key, val) where [
  key.Eq,
  key.Hash,
]

view this post on Zulip Richard Feldman (Jul 11 2025 at 01:40):

I think I'd make the where one line in that case:

Dict.insert : Dict(key, val), key, val -> Dict(key, val)
    where [key.Eq, key.Hash]

view this post on Zulip Luke Boswell (Jul 11 2025 at 01:41):

Dict.insert : Dict(key, val), key, val -> Dict(key, val) where [
  key.equals : key, key -> Bool,
  key.hash : key, hasher -> hasher where [ hasher.Hasher ],
]

view this post on Zulip Luke Boswell (Jul 11 2025 at 01:41):

Don't ask me to make hasher anonymous too :sweat_smile:

view this post on Zulip Richard Feldman (Jul 11 2025 at 01:42):

the problem I have is this one though:

decode_json : List(U8) -> Result(elem, [DecodeErr]) where [
  elem.decode : List(U8) -> Result(elem, [DecodeErr]),
]

the problem is that I read elem.decode as like "decode is either a field or a method on elem" and it's neither

view this post on Zulip Richard Feldman (Jul 11 2025 at 01:44):

here's a variation on that design:

Dict.insert : Dict(key, val), key, val -> Dict(key, val) where [
    module(key).eq : key, key -> Bool,
    key.Hash,
]
Dict.insert : Dict(key, val), key, val -> Dict(key, val)
    where [key.Eq, key.Hash]
decode_json : List(U8) -> Result(elem, [DecodeErr])
    where [elem.Decodable]
decode_json : List(U8) -> Result(elem, [DecodeErr]) where [
    module(elem).decode : List(U8) -> Result(elem, [DecodeErr]),
]

view this post on Zulip Richard Feldman (Jul 11 2025 at 01:44):

this is my favorite of the syntaxes we've discussed so far!

view this post on Zulip Richard Feldman (Jul 11 2025 at 01:45):

:check: no parsing ambiguity
:check: anonymous constraints are clear
:check: non-anonymous constraints are concise
:check: mixed anonymous/non-anonymous looks nice

view this post on Zulip Richard Feldman (Jul 11 2025 at 01:49):

what do others think of that one? :point_up:

(setting aside how you declare Hash and Eq for now, since that can be discussed separately once we've settled on a type annotation syntax)

view this post on Zulip Luke Boswell (Jul 11 2025 at 01:49):

view this post on Zulip Jared Ramirez (Jul 11 2025 at 02:01):

I like it! Agree it’s both clear and concise for anon and non-anon

view this post on Zulip Luke Boswell (Jul 11 2025 at 02:07):

Here is all my notes update to this design (I think I got everything)

https://gist.github.com/lukewilliamboswell/913bd6e9ce2f7094eb4ae74cb8a903d1

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 02:10):

I hope I'll finish cooking my proposal before the where clause is implemented :sweat_smile:
It's left to fill a couple of md sections

view this post on Zulip Luke Boswell (Jul 11 2025 at 02:14):

One thing that doesn't quite sit right with me is that module(a) is saying a must be a nominal type that implicitly satisfies this constraint (has this method etc).

But at first glance it reads like "the module of a", which is only part of the story.

I don't have any good ideas though, just wanted to mention this.

view this post on Zulip Jared Ramirez (Jul 11 2025 at 02:18):

I know I said this earlier, but I think it's important to colocate where [elem.Eq] with it's usage, and not with it's type. Meaning, I don't think this should be allowed:

Dict(k,v) := ... where [ k.Eq, k.Hash ]

If we allow this, the you'll be forced to add where clauses even when it's not used, like:

dict_to_pairs : Dict(k, v) -> List({key: k, value: v})
dict_to_pairs = |dict| {
    var pairs = []
    for {key, value} in dict {
        pairs = pairs.append({key, value})
    }
    pairs
}

This function doesn't use Eq or Hash, but the type system would require you to to defined the function as:

dict_to_pairs : Dict(k, v) -> List({key: k, value: v}) where [ k.Eq, k.Hash ]

Because k is passed to Dict(k, v), and Dict says that k must have Eq and Hash

view this post on Zulip Luke Boswell (Jul 11 2025 at 02:21):

I don't think this should be allowed

Is that a stronger position than we need. Do you mean, we shouldn't define Dict in this way, or that you shouldn't be able to constrain type variables at all on any nominal type?

view this post on Zulip Luke Boswell (Jul 11 2025 at 02:22):

For our builtin Dict we should put these constraints in the insert and get methods right.

Is there ever a valid use case where someone may actually want to constrain it like that though?

view this post on Zulip Jared Ramirez (Jul 11 2025 at 02:22):

IMO it should not be allowed at all. I'm mainly drawing from my experince with Haskell (which is my day job language) where it's allowed to do this kind of thing, but in every beginner guide it says "but don't actually do this" for the reason above

view this post on Zulip Jared Ramirez (Jul 11 2025 at 02:23):

Yes, insert/get and other function that use the whatever dispatched function should have it!

view this post on Zulip Jared Ramirez (Jul 11 2025 at 02:24):

But IMO each function should have only the minimum where constraints necessary to do its work

view this post on Zulip Luke Boswell (Jul 11 2025 at 02:24):

Sounds like a good idea to have this restriction then!

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 02:42):

https://gist.github.com/kdziamura/8148acd0b0cdf0a1e704f621d6b19396

Here. I tried really hard to address everything I could think about. Also tried to comment every step and explain the concept slowly, step by step. However feel free to hop straight to the things you're intrested in. There's also a good alternative to self keyword that indeed makes most things much simpler. The biggest downside for me there is possible indentation hell, but I'm sure it can be addressed as well.

Please give me some feedback on both good and bad sides.

I'll review @Luke Boswell's proposal and the discussion around it tomorrow. I have a couple hours of sleep left and I want to take that chance :sweat_smile:

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 02:45):

Also, if it's too painful to see self everywhere, I can write a proposal that applies the alternative from the last section. But it's tomorrow

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 02:57):

I see we open a can of worms here... cool!

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 02:58):

Jared Ramirez said:

I know I said this earlier, but I think it's important to colocate where [elem.Eq] with it's usage, and not with it's type. Meaning, I don't think this should be allowed:

Dict(k,v) := ... where [ k.Eq, k.Hash ]

I shall forever be sad.............. but I understand.

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 02:58):

If this is the case then we only ever need where clauses on functions...which kinda logically makes sense. Cause functions do things. And where lists things to do

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 02:59):

And then of course we need interface definitions.

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 03:04):

@Kiryl Dziamura proposal looks like a pretty solid base.

module(variable) looks odd, but I guess it is required? I would prefer module(type_var), but then the function would require a type annotation. Though for return type based dispatch I think we need module(type_var). So I think I am still in favor of that.

I am not a fan of self. The comment at the end here does not make sense to me:

interface Sortable : {
    order : (elem, elem) -> [LT, EQ, GT] where (elem: Sortable)
}

Why would where (elem: Sortable) be needed?

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 03:06):

I guess we could avoid this by doing:

interface Sortable : {
    module(elem).order : (elem, elem) -> [LT, EQ, GT],
}

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 03:07):

That also enables things like:

interface ReturnTypeDispatch : {
    module(elem).generate : input_data -> elem where (input_data : Hash),
}

view this post on Zulip Richard Feldman (Jul 11 2025 at 03:09):

@Brendan Hansknecht the "Luke's proposal" Kiryl referred to is this

view this post on Zulip Richard Feldman (Jul 11 2025 at 03:09):

(for comparison)

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 03:13):

I see

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 03:13):

How is Hash defined in that proposal?

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 03:13):

Also key.Hash still feels kinda odd to me.

view this post on Zulip Luke Boswell (Jul 11 2025 at 03:23):

Thank you for writing your idea up @Kiryl Dziamura, I found it much easier to follow.

I think there are a lot of similarities with our designs, and it's helpful to have a point of comparison. I think your idea could work well, I don't see any major issues, mostly just different trade-offs.

Some thoughts.

>> |dict, key| { dict.get(key) }

a, b -> c where (a : { get : (self, b) -> c })

view this post on Zulip Luke Boswell (Jul 11 2025 at 03:29):

Luke Boswell said:

Here is all my notes update to this design (I think I got everything)

https://gist.github.com/lukewilliamboswell/913bd6e9ce2f7094eb4ae74cb8a903d1

@Brendan Hansknecht did you miss this link?

It includes more worked examples

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 03:31):

yes

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 03:31):

>> |dict, key, val| { dict.insert(key, val) }

a, b, c -> a where [ module(a).insert : b, c -> a ]

This is wrong? Should be:

>> |dict, key, val| { dict.insert(key, val) }

a, b, c -> d where [ module(a).insert : a, b, c -> d ]

view this post on Zulip Richard Feldman (Jul 11 2025 at 03:34):

I'd like to hold off on discussing declaration syntax, so we can focus on type annotation syntax

view this post on Zulip Richard Feldman (Jul 11 2025 at 03:35):

I think those are sufficiently decoupled, and there are already enough concerns to juggle just when it comes to type annotations that it will be helpful to do a separate thread later on declarations after type annotation syntax is settled :smile:

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 03:35):

I think they should be tied some. I really hate seeing Sort(elem) at declaration and elem.Sort in type annotations

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 03:35):

It feels inconsistent

view this post on Zulip Richard Feldman (Jul 11 2025 at 03:36):

we can always bring up consistency in the other thread

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 03:36):

Damn, can't sleep.

Thank you for the review

Type system would assume dict is a record, no?

>> |dict, key| { dict.get(key) }

{ get : a -> b }, a -> b

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 03:37):

no, it is static dispach

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 03:37):

record functions require extra parens

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 03:37):

(dict.get)(key) <- this is a record

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 03:38):

Til

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 03:39):

yeah, that was an old debate

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 03:39):

Also, I guess at declaration, we could just do
Sort :: [ module(elem).order : elem, elem -> [LT, EQ, GT] ]

Then elem.Sort wouldn't feel too odd.

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 03:40):

elem.Sort

Why not module(elem) : Sort?

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 03:40):

I would be happy with that too.

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 03:40):

I think both proposal have ok overall syntax

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 03:41):

Could even go as far as elem : Sort technically.

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 03:42):

Anyway, for where clauses specifically.

a, b, c -> d where [ module(a).insert : a, b, c -> d ]

a, a -> a where [ module(a): Sort ]

That does have a nice parity

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 03:43):

since everything in where clause constraints modules I think module(elem).order can be replaced with elem.order as well

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 03:44):

Yeah, why have a.Sort? I think module(a) : Sort or a : Sort would be nicer and mirrors well to module(a).insert : a, b, c -> d or I guess a.insert : a, b, c -> d

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 03:45):

Otherwise, I think the rest of Luke's where clause syntax looks good.

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 03:47):

Dict.insert : Dict(key, val), key, val -> Dict(key, val) where [
    key.eq : key, key -> Bool,
    key : Hash,
]
Dict.insert : Dict(key, val), key, val -> Dict(key, val)
    where [key : Eq, key : Hash]
decode_json : List(U8) -> Result(elem, [DecodeErr])
    where [elem : Decodable]
decode_json : List(U8) -> Result(elem, [DecodeErr]) where [
    elem.decode : List(U8) -> Result(elem, [DecodeErr]),
]

view this post on Zulip Luke Boswell (Jul 11 2025 at 03:47):

The reason we include module was discussed earlier. Basically we must have anonymous versions of these things, and the name version is only optional. When you start reverse engineering what the REPL would spit out we ended up with the module(a). syntax

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 03:48):

In inference you mean?

view this post on Zulip Luke Boswell (Jul 11 2025 at 03:49):

See #ideas > where clause changes @ 💬

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 03:53):

module is never needed. It is just a lot clearer.

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 03:53):

What's the problem with this? I really don't get it. It's a valid constraint. a is type var, a.insert refers to its insert exported frok the module

>> |dict, key, val| { dict.insert(key, val) }

a, b, c -> d where [ a.insert : a, b, c -> d ]

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 03:54):

The important note being that it is not a.insert : b, c -> d. This is not forcing static dispatch where the first arg must be a.

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 03:55):

It is just leaving out the word module. Clearly if the word module is required 100% of the time, it must not be needed here.

view this post on Zulip Luke Boswell (Jul 11 2025 at 03:55):

Wait... does this work?

>> |bytes| module(elem).decode(bytes)

Here elem would have been in the return type...

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 03:55):

Ofc, as I said, everything inside of where clause constraints modules, not types

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 03:56):

What is elem @Luke Boswell

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 03:56):

>> |bytes| module(elem).decode(bytes)

I don't think that works in any system

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 03:56):

It needs a type defintion

view this post on Zulip Luke Boswell (Jul 11 2025 at 03:57):

from_json : List(U8) -> Result(elem, [DecodeErr]) where [
  elem.decode : List(U8) -> Result(elem, [DecodeErr]),
]

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 03:58):

>> |bytes| module(bytes).decode(bytes)

a -> b where [a.decode: a -> b ]

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 03:58):

>> |bytes| module(elem).decode(bytes)

a -> b where [ module(elem).decode : a -> b ]

:point_up: is broken.

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 03:59):

Kiryl Dziamura said:

>> |bytes| module(bytes).decode(bytes)

a -> b where [a.decode: a -> b ]

You have to refer to something in the scope. module can be comptime but rely only on existing variable names, it shouldn't introduce any type vars

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 04:00):

Otherwise it violates the main HM principle of full inference without type annotations

view this post on Zulip Luke Boswell (Jul 11 2025 at 04:01):

See also #ideas > where clause changes @ 💬

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 04:02):

Could you please give an example in code?

view this post on Zulip Luke Boswell (Jul 11 2025 at 04:03):

So in >> |bytes| module(elem).decode(bytes) we know that there is a nominal type elem that has a method decode

So I think it's actually fine.

>> |bytes| module(elem).decode(bytes)

a -> b where [ module(c).decode : c, a -> b ]

Where usually the b would be a Result(elem, [...]) etc

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 04:04):

That type doesn't make sense. You didn't pass an argument for c to decode.

view this post on Zulip Luke Boswell (Jul 11 2025 at 04:05):

The module(...) works on a type var

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 04:05):

Yes, and it has no sense

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 04:05):

It can make sense but only with a user defined type

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 04:07):

I don't think module(elem) can be anonymous like that

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 04:07):

Just like today when you use decode, the output type has to be concrete

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 04:07):

It doesn't makes sense because it's an implementation world. You also can't bring anything unrelated from defined vars here so I'm not sure why this type-inside-of-call complication

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 04:10):

Fundamentally, we want to support (or something roughly equivalent):

| bytes | {
    result : Result a err = module(a).decode(bytes)
    result
}

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 04:10):

But I think it requires something to have an explicit user type annotation to anchor the type variable.

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 04:12):

Brendan Hansknecht said:

Fundamentally, we want to support (or something roughly equivalent):

| bytes | {
    result : Result a err = module(a).decode(bytes)
    result
}

a doesn't have any sense here, how it would be inferred if it's not connected with type of bytes? I think the real concern is returned type

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 04:12):

Aha. That's the Richard's pain point

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 04:13):

Yeah, we still want decode to work.

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 04:13):

So we need a syntax for return type based dispatch

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 04:14):

I think what I wrote above is the current idea (or roughly close)

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 04:14):

I really don't like how it brings type var into my beloved pure hm inference

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 07:08):

The ultimate goal is to get rid of the special case comptime function module(type) in implementation. It's likely not possible. And it means anonymous interface is also not possible. You have to take type from somewhere. If not for compiler but for sanity at least.

>> |bytes| module(ok).decode(bytes)

Like, ok can't pop up here from nowhere, it has to be specified:

f : _ -> Result(ok, _)
f = |bytes| {
    module(ok).decode(bytes)
}

Or

|bytes| {
    res : Result(ok, _)
    res = module(ok).decode(bytes)
    res
}

It's sad to say at least

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 10:38):

I know, it's unwanted, but anyway. Blue-sky thinking. What if we require (or do it automatically) modules to combine functions that have the module type not as first arg, to a record (let's name it helpers) and then pass it from outside when it's needed? Yes, it's a slight step back from static dispatch. At least it's explicit and doesn't require comptime function. Yes, I know namespaces arent records

fn = | helpers, bytes | {
    (helpers.decode)(bytes)
}

res = fn(U8.helpers, bytes)

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 10:41):

Inferred type

>> | helpers, bytes | (helpers.decode)(bytes)

{ decode : a -> b }, a -> b

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 10:43):

Need to try in a real world scenario

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 11:05):

Or maybe even allow passing modules as records :man_shrugging: fn(U8, bytes)

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 15:14):

If you consider module a compile time function, we have it no matter what with static dispatch

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 15:14):

It just is hidden in the standard case

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 15:14):

a.insert(b) is module(type(a)).insert(a,b)

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 15:15):

I get that it has an anchoring variable, but from the compiler perspective they are the same

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 15:17):

Clearly return type dispatch (as with all other static dispatch) could be implemented with lambdas.

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 15:18):

Really, I think the only current use case for this is decode

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 15:20):

The biggest problem with decode via lambdas is the function signature won't be consistent

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 15:25):

To decode an I64, it is just decode: bytes -> Result(I64, DecodeError)

That is simple, but this gets most problematic is complex nested types. To decode a Dict(k, v) via lambdas, you have to pass a decode function in for k and v.
decode: bytes, (bytes -> Result(k, DecodeError), (bytes -> Result(v, DecodeError) -> Result(Dict(k, v), DecodeError)

So without something like static dispatch or abilities, you lose a consistent and easy to use decode function that is consistent for all types.

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 15:35):

comptime function

I mean the explicit use of typevar as an arg of a function

But what bothers me more, is that in cases such as decode, you have to stop and ponder on type design. LSP might help generating the anno tho

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 15:40):

Comp time function or not I don't think is relevant. I would not call module a function at all.

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 15:40):

Anyway, yeah, it is a subsection of roc that requires types

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 15:41):

One way around it is to force use of an interface

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 15:41):

But that is not a syntax we support currently and would feel a lot more like current abilites

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 15:45):

Basically, if you have:

Decode :: [
    module(elem).decode : List(U8) -> Result(elem, err)
]

Instead of requiring module(elem).decode(bytes)
You could instead allow/require:
Decode.decode(bytes)

Basically allow calling functions in interfaces directly.

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 15:45):

Then the type system would know it was doing dispatch of the ok type of the result returned

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 15:46):

What are folks thought on that instead of ever putting module(elem) directly in code?

CC: @Luke Boswell @Richard Feldman @Kiryl Dziamura

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 15:47):

This means a for return type dispatch, you must always define an interface

view this post on Zulip Richard Feldman (Jul 11 2025 at 15:47):

sounds like a massive downside...what's the upside? :sweat_smile:

view this post on Zulip Richard Feldman (Jul 11 2025 at 15:47):

like now it's no longer structural ad-hoc polymorphism, which was a selling point

view this post on Zulip Richard Feldman (Jul 11 2025 at 15:47):

it's become nominal

view this post on Zulip Richard Feldman (Jul 11 2025 at 15:47):

and for what?

view this post on Zulip Richard Feldman (Jul 11 2025 at 15:48):

you're still forced to write a type annotation and then refer to it

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 15:48):

Cause return type dispatch is already kinda broken and requires typing anyway

view this post on Zulip Richard Feldman (Jul 11 2025 at 15:49):

right but why is it better to be forced to refer to the name Decode, which is the name of a type annotation, instead of being forced to refer to the name of a type variable?

view this post on Zulip Richard Feldman (Jul 11 2025 at 15:50):

this seems to have the same downsides as the status quo design (namely that you are required to declare a name outside of expression-land and refer to that name in expression-land) but with additional downsides on top of it

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 15:50):

Fair enough....I guess that just reads less magical cause it feels like abilities or like calling a function in a module....just happens to be the decode interface which refers to some other module

view this post on Zulip Richard Feldman (Jul 11 2025 at 15:50):

to me, I think it's important to at least support something like module(var_name) so that if you want to you can be explicit about which specific type you want to dispatch on

view this post on Zulip Richard Feldman (Jul 11 2025 at 15:51):

there's a reasonable separate question if we want that to be the only way to do certain types of dispatch, or if we want to offer additional other ways that don't require referring to a name

view this post on Zulip Richard Feldman (Jul 11 2025 at 15:51):

for example, obviously if the type var is in the first argument position, we already have a syntax for that

view this post on Zulip Richard Feldman (Jul 11 2025 at 15:51):

but we don't have a syntax for if the type var happens to be in the return type position

view this post on Zulip Richard Feldman (Jul 11 2025 at 15:51):

a challenge is that type vars can be in a lot of positions

view this post on Zulip Richard Feldman (Jul 11 2025 at 15:52):

first arg and return var are just the tip of a gigantic iceberg

view this post on Zulip Richard Feldman (Jul 11 2025 at 15:52):

for example you could have a record type with a tag union in there, and then you're trying to dispatch on something that's buried several levels deep

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 15:52):

If you think module(type_var) is required, I definitely don't think we should support extra syntaxes for it

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 15:53):

I can't see how you can be not explicit about specific var :thinking:
nvm

view this post on Zulip Richard Feldman (Jul 11 2025 at 15:53):

yeah, creating the guarantee that it's always possible to dispatch on any part of a type without referring to a type variable by name seems like it would add a ton of complexity on top of module(var_name) to support use cases that would almost never come up in practice

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 15:54):

I'm not sure that is true. My alternative is not particularly complex and supports all location dispatch... It just requires and interface which is inwanted

view this post on Zulip Richard Feldman (Jul 11 2025 at 15:54):

so I like the balance of:

view this post on Zulip Richard Feldman (Jul 11 2025 at 15:54):

Brendan Hansknecht said:

I'm not sure that is true. My alternative is not particularly complex and supports all location dispatch... It just requires and interface which is inwanted

what I mean by "anonymous" is "without names"

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 15:54):

Anyway, I'm happy with module(type_var) it will be pretty rare anyway.

view this post on Zulip Richard Feldman (Jul 11 2025 at 15:55):

as in, you could write it with just a plain lambda that has no names involved

view this post on Zulip Richard Feldman (Jul 11 2025 at 15:55):

e.g. you can do this today if you want:

(|_| 1 + 1).foo(42)

view this post on Zulip Richard Feldman (Jul 11 2025 at 15:55):

(I don't know why you'd want to, but you can)

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 15:56):

The upside of the design Brendan suggests is that you think it terms of constraints and not the type of the particular use case

view this post on Zulip Richard Feldman (Jul 11 2025 at 15:56):

the point is that although it feels different to have module(var_name).foo vs ThingName.foo, they both are in the category of "dispatching on a named thing" - you have to formally declare a name for a thing in order to use it

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 15:56):

Is this considered a lambda with no names involved?

|bytes|
    res : Result ok err
    res = module(ok).decode(bytes)
    res

That really doesn't feel like no names to me

view this post on Zulip Richard Feldman (Jul 11 2025 at 15:57):

oh it's not no names

view this post on Zulip Richard Feldman (Jul 11 2025 at 15:57):

my point is that they both have names

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 15:57):

Ah

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 15:57):

And you agree names are required here?

view this post on Zulip Richard Feldman (Jul 11 2025 at 15:57):

I don't think they're innately required, just that the language complexity cost of preventing names seems prohibitive

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 15:57):

Sure

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 15:58):

Ok. Same page

view this post on Zulip Richard Feldman (Jul 11 2025 at 15:58):

and I think if we are going to require names, it's better to maintain the structural ad-hoc polymorphism design

view this post on Zulip Richard Feldman (Jul 11 2025 at 15:58):

(as in, you don't have to define an Ability - or similar - up front)

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 16:00):

Yesh, fundamentally the other syntax, while looking cleaner/more natural from a type perspective in many languages, really is just a restricted form of module(a) and must do all the same compile type checking work.

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 16:02):

Super duper oneliner

|bytes| module([a.decode : _ -> Result(a, _)]).decode(bytes)

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 16:05):

That.... Is something.....maybe we don't always need one liners.

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 16:18):

Tbh the more I think about it, the more I like it. It gives you ability of describing module constraints in place as an option. Limiting your flight of thought about the outer type, isolating on the specific problem. And then you can just extract it to a meaningful constraint.

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 16:19):

But it's too verbose ofc

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 16:20):

Also, why not sigil? (Sorry) @module since it's a special thing. I don't care but have to ask

view this post on Zulip Brendan Hansknecht (Jul 11 2025 at 16:22):

Kiryl Dziamura said:

Tbh the more I think about it, the more I like it. It gives you ability of describing module constraints in place as an option. Isolating your flight of thought about the outer type. And then you can just extract it to a meaningful constraint.

If it was always used like that in where clauses (and interface definitions), it would be pretty reasonable here as well...but yeah, mostly verbose.

view this post on Zulip Kiryl Dziamura (Jul 11 2025 at 16:24):

I mean, it's expected to be fairly rare.
Also expects only one function to be described so maybe there's a way to make it shorter

view this post on Zulip Richard Feldman (Jul 11 2025 at 16:57):

Kiryl Dziamura said:

Also, why not sigil? (Sorry) @module since it's a special thing. I don't care but have to ask

module is already a reserved keyword, so might as well use it! :smile:

view this post on Zulip Sky Rose (Jul 12 2025 at 12:59):

What happens if you do this in a repl?

f = |bytes| either definition above
f([concrete, list, of, bytes])

What decode function gets run, and what value (and what type) gets returned?

view this post on Zulip Kiryl Dziamura (Jul 12 2025 at 13:27):

Type error I think

view this post on Zulip Richard Feldman (Jul 12 2025 at 13:32):

I'm failing to mentally desugar what "either definition above" would actually be here :sweat_smile:

view this post on Zulip Richard Feldman (Jul 12 2025 at 13:32):

can you write out the exact thing that would go in the repl?

view this post on Zulip Kiryl Dziamura (Jul 12 2025 at 13:37):

Brendan Hansknecht said:

|bytes|
    res : Result ok err
    res = module(ok).decode(bytes)
    res

view this post on Zulip Sky Rose (Jul 12 2025 at 13:41):

Kiryl Dziamura said:

Super duper oneliner

|bytes| module([a.decode : _ -> Result(a, _)]).decode(bytes)

view this post on Zulip Kiryl Dziamura (Jul 12 2025 at 13:45):

It would work the same way as Brendan's snippet

view this post on Zulip Sky Rose (Jul 12 2025 at 13:48):

Kiryl Dziamura said:

Type error I think

If it's a type error, does that mean

f = |bytes| module([a.decode : _ -> Result(a, _)]).decode(bytes)
res = f([concrete, list, of, bytes])

Is a type error but

Does this mean

f = |bytes| module([a.decode : _ -> Result(a, _)]).decode(bytes)
res : Result(String, _)
res = f([concrete, list, of, bytes])

works? Like you still need to define a type signature, you've just passed the burden to the caller?

view this post on Zulip Sky Rose (Jul 12 2025 at 13:49):

All this work to enable dispatch on the output types seems to lead to a function whose behavior depends on its outputs instead of its inputs, which is weird.

I know there's ergonomic benefits for decoding, but is it worth it?

view this post on Zulip Kiryl Dziamura (Jul 12 2025 at 13:50):

But it's not a problem with the module. Like, it's unrelated

view this post on Zulip Kiryl Dziamura (Jul 12 2025 at 13:54):

The type of the function we're talking about is fn : List(U8) -> a. How would a fn with the type act in repl?

view this post on Zulip Richard Feldman (Jul 12 2025 at 13:57):

I think the way decoding works in Roc is a major selling point of the language

view this post on Zulip Richard Feldman (Jul 12 2025 at 13:57):

the way I think about it is that whatever design we end up with has a hard requirement of continuing to support that :smile:

view this post on Zulip Brendan Hansknecht (Jul 12 2025 at 16:13):

Sky Rose said:

Like you still need to define a type signature, you've just passed the burden to the caller?

100% and there is no way around it if you want decoding or similar abilities (which we definitely do).

And important extra note here, for really programs, this often is not a problem. You don't have to specify the type of you use the type in a way that verify what the type is.

Hmm....actually in the new world of all static dispatch, defining the type likely is required a lot more often....so I guess it is a bit sad.

view this post on Zulip Richard Feldman (Jul 12 2025 at 16:26):

what's an example of where you would need to define the type?

view this post on Zulip Richard Feldman (Jul 12 2025 at 16:26):

right now building a Decoder library (e.g. literally authoring the json package) is the only situation I'm aware of where you would need to specify a type

view this post on Zulip Brendan Hansknecht (Jul 12 2025 at 16:32):

If you decode a type and then only ever use it with static dispatch. Before you would have used it with a concrete function and that would have given the compiler the type info. If you decode a list, call append, and then reencode it, with static dispatch the compiler has no way to know the type.

view this post on Zulip Richard Feldman (Jul 12 2025 at 16:34):

that's always been true

view this post on Zulip Richard Feldman (Jul 12 2025 at 16:34):

it's true with Abilities too

view this post on Zulip Richard Feldman (Jul 12 2025 at 16:35):

Ayaz and I talked about the "decode then re-encode without using it in a concrete way" needing to be a compiler error, but I forget if we ever actually implemented it :sweat_smile:

view this post on Zulip Richard Feldman (Jul 12 2025 at 16:35):

at any rate, we'll certainly need it again!

view this post on Zulip Brendan Hansknecht (Jul 12 2025 at 16:38):

Oh, I just mean it will be common place now

view this post on Zulip Brendan Hansknecht (Jul 12 2025 at 16:38):

Cause static dispatch is the default way to interact with types

view this post on Zulip Brendan Hansknecht (Jul 12 2025 at 16:38):

I know it could happen in the old compiler, but it was rare in comparison

view this post on Zulip Brendan Hansknecht (Jul 12 2025 at 16:39):

So minor ergonomics loss requiring typing more often

view this post on Zulip Richard Feldman (Jul 12 2025 at 16:39):

ah I see

view this post on Zulip Richard Feldman (Jul 12 2025 at 16:39):

I guess theoretically? haha

view this post on Zulip Richard Feldman (Jul 12 2025 at 16:40):

I'd assume when you're not writing out any type annotations, you're probably decoding into plain records (as opposed to having defined custom nominal types)

view this post on Zulip Richard Feldman (Jul 12 2025 at 16:40):

which wouldn't be any different

view this post on Zulip Richard Feldman (Jul 12 2025 at 16:41):

and if you are writing out custom nominal types to decode into, I'd assume you would also be writing type annotations in a code base like that

view this post on Zulip Richard Feldman (Jul 12 2025 at 16:41):

so I'm not worried about it being a concern in practice, but I guess we'll see!

view this post on Zulip Kiryl Dziamura (Jul 12 2025 at 16:53):

Should module interfaces (or whatever we would call them) work like traits? E.g.

|bytes| Decode.decode(bytes)

view this post on Zulip Brendan Hansknecht (Jul 12 2025 at 17:00):

I brought this up earlier and the preferred answer was no.

view this post on Zulip Kiryl Dziamura (Jul 12 2025 at 17:03):

I should reread the convo. It feels so natural. Num.add(a, b), Addable.add(a, b)

view this post on Zulip Richard Feldman (Jul 12 2025 at 17:04):

Kiryl Dziamura said:

Super duper oneliner

|bytes| module([a.decode : _ -> Result(a, _)]).decode(bytes)

I'd be interested to explore this more. What I like about it is that it doesn't introduce the precedent of using type variables in expressions, and what I don't like about it is that it's super verbose if you write it all out like this.

I think the module(type_var) is also fine, but I think we should see if we can get this general idea to look nice when using it in practice.

For example, could we make it more concise with a type alias (or interface declaration or whatever we decide to call them) in the module(____) part? What would that look like?

view this post on Zulip Richard Feldman (Jul 12 2025 at 17:06):

Kiryl Dziamura said:

Should module interfaces (or whatever we would call them) work like traits? E.g.

|bytes| Decode.decode(bytes)

Brendan Hansknecht said:

I brought this up earlier and the preferred answer was no.

I don't understand the question or the answer :laughter_tears:

view this post on Zulip Richard Feldman (Jul 12 2025 at 17:07):

like certainly |a| B.c(a) is valid Roc code, so why wouldn't |bytes| Decode.decode(bytes) work?

view this post on Zulip Brendan Hansknecht (Jul 12 2025 at 17:12):

Brendan Hansknecht said:

Basically, if you have:

Decode :: [
    module(elem).decode : List(U8) -> Result(elem, err)
]

Instead of requiring module(elem).decode(bytes)
You could instead allow/require:
Decode.decode(bytes)

Basically allow calling functions in interfaces directly.

I think it is the same discussion as started here

view this post on Zulip Richard Feldman (Jul 12 2025 at 17:15):

ah :+1:

view this post on Zulip Richard Feldman (Jul 12 2025 at 18:07):

Richard Feldman said:

Kiryl Dziamura said:

Super duper oneliner

|bytes| module([a.decode : _ -> Result(a, _)]).decode(bytes)

I'd be interested to explore this more.

hm, I think this is incomplete. As I recall, we concluded that _ couldn't be allowed in Ability definitions (I'm not sure if that's true in static dispatch as well, haven't really thought about it) but if I take out the _s, I realize we're missing something important:

|bytes|
    module({ decode : List(U8) -> Result(val, Err) }).decode(bytes)

as written, val is unbound - so this is saying that val is essentially *

view this post on Zulip Richard Feldman (Jul 12 2025 at 18:08):

so we'd need some way to bind it, e.g.

|bytes|
    module(val -> { decode : List(U8) -> Result(val, Err) }).decode(bytes)

view this post on Zulip Brendan Hansknecht (Jul 12 2025 at 18:16):

Isn't _ just a or b or an arbitrary type variable?

view this post on Zulip Brendan Hansknecht (Jul 12 2025 at 18:17):

Also, what do you mean by "bound"? Like we don't know it is what needs to be dispatched on?

view this post on Zulip Kiryl Dziamura (Jul 12 2025 at 18:30):

Free variable. But it can be inferred, I see no problems. At least in this particular place

view this post on Zulip Kiryl Dziamura (Jul 12 2025 at 18:31):

Ah.. I see

view this post on Zulip Kiryl Dziamura (Jul 12 2025 at 18:33):

But in the original oneliner it was bound: a.decode

view this post on Zulip Kiryl Dziamura (Jul 12 2025 at 18:36):

It could be even this:

|bytes| module([a._ : _ -> Result(a, _)]).decode(bytes)

So even the decode name is inferred from the use. Because you can call only single function from the interface there. Only a can't be inferred

view this post on Zulip Kiryl Dziamura (Jul 12 2025 at 18:53):

We only need annotate the thing module(type_of_the_thing).thing

E.g.

|bytes| module(a where _ -> Result(a, _)).decode(bytes)

Demands better readability, yes

view this post on Zulip Kiryl Dziamura (Jul 13 2025 at 05:03):

Want to discuss |bytes| Decodable.decode(bytes) once again. The main concern was nominality. But from my perspective it doesn't mean that a type should fully implement Decodable. For me this call means that a type should implement specifically Decodable.decode.

module (or whatever alternative we find) is still should be an option for an inlined type resolution. But I don't think there's a reason to forbid Decodable.decode(bytes) approach

view this post on Zulip Jared Ramirez (Jul 30 2025 at 16:21):

Based on this convo, i'm planning on disallowing where clauses in type declarations for now in my next PR. So the following (which is currently parsed/canonicalized) would report now report a diagnostic:

Sort(a) : a
    where module(a).order : (a, a) -> [LT, EQ, GT]

view this post on Zulip Jared Ramirez (Jul 30 2025 at 16:22):

Then currently the only place you would be able to have a where clause is in an annotation, ie:

sort : List(elem) -> List(elem) where module(elem).order : (elem, elem) -> [LT, EQ, GT]
sort = ...

view this post on Zulip Jared Ramirez (Jul 30 2025 at 16:23):

At least until this thing we're talking about here is fully defined/implemented!

view this post on Zulip Richard Feldman (Sep 01 2025 at 02:15):

Luke Boswell said:

One thing that doesn't quite sit right with me is that module(a) is saying a must be a nominal type that implicitly satisfies this constraint (has this method etc).

But at first glance it reads like "the module of a", which is only part of the story.

I don't have any good ideas though, just wanted to mention this.

I thought of a solution to this that I like: instead of module(elem).foo : it's elem.module.foo : like so:

Dict.insert : Dict(key, val), key, val -> Dict(key, val) where [
    key.module.equals : key, key -> Bool,
    key.Hash,
]
Dict.insert : Dict(key, val), key, val -> Dict(key, val)
    where [key.Eq, key.Hash]
decode_json : List(U8) -> Result(elem, [DecodeErr])
    where [elem.Decodable]
decode_json : List(U8) -> Result(elem, [DecodeErr]) where [
    elem.module.decode : List(U8) -> Result(elem, [DecodeErr]),
]

view this post on Zulip Richard Feldman (Sep 01 2025 at 02:18):

and here's a proposed syntax for defining the where aliases: (not sure what to call them yet)

val.Eq : [val.module.equals : val, val -> Bool]

view this post on Zulip Richard Feldman (Sep 01 2025 at 02:18):

the basic idea is that for a type alias you'd write Eq(val) : but for one of these you'd write val.Eq : instead, because that's how these show up in type annotations

view this post on Zulip Richard Feldman (Sep 01 2025 at 02:18):

these would share a namespace with types, so you'd just write Eq in your module's list of exposed things like normal (not like val.Eq or anything like that)

view this post on Zulip Richard Feldman (Sep 01 2025 at 02:19):

also writing it like this makes it syntactically obvious that you only get one variable - e.g. with type aliases you can have multiple variables (such as Pair(a, b) :) but that's not a thing with these - and this syntax makes that obvious, because you can't even write more than one variable!

view this post on Zulip Luke Boswell (Sep 01 2025 at 02:27):

It's kind of so obvious I can't believe we didn't think of that before

view this post on Zulip Luke Boswell (Sep 01 2025 at 02:28):

I like it

view this post on Zulip Luke Boswell (Sep 01 2025 at 02:28):

As in -- now that we are looking at it, it just makes sense... but I don't think we raised this as an option at the time

view this post on Zulip Luke Boswell (Sep 01 2025 at 02:29):

Richard Feldman said:

and here's a proposed syntax for defining the where aliases: (not sure what to call them yet)

val.Eq : [val.module.equals : val, val -> Bool]

They are "constraints" right?

view this post on Zulip Richard Feldman (Sep 01 2025 at 02:29):

yeah sometimes it takes awhile for an obvious design to appear :joy:

view this post on Zulip Luke Boswell (Sep 01 2025 at 02:29):

As in literally these are the constraints used in the HM type inference algorithm

view this post on Zulip Richard Feldman (Sep 01 2025 at 02:29):

they are constraints, but so are lots of things

view this post on Zulip Richard Feldman (Sep 01 2025 at 02:30):

that said, "type alias" and "constraint alias" could be an interesting split :thinking:

view this post on Zulip Luke Boswell (Sep 01 2025 at 02:30):

"so are lots of things" -- I'm curious what else is?

view this post on Zulip Richard Feldman (Sep 01 2025 at 02:31):

a -> a is a constraint - both arg and return type are constrained to be the same type

view this post on Zulip Luke Boswell (Sep 01 2025 at 02:31):

I looked at that and just thought it was an alias... just without a "type" annotation part

view this post on Zulip Richard Feldman (Sep 01 2025 at 02:31):

right haha

view this post on Zulip Luke Boswell (Sep 01 2025 at 02:32):

Like all type annotations are constraints, and a type alias has the general shape <header> : <type> <clauses>

view this post on Zulip Luke Boswell (Sep 01 2025 at 02:32):

val.Eq : {} where [val.module.equals : val, val -> Bool] -- is this unnecessarily verbose? not sure it even makes sense

view this post on Zulip Brendan Hansknecht (Sep 01 2025 at 02:36):

I like this syntax. Just wait for the module function though

document.module.module : ...


Last updated: Jun 16 2026 at 16:19 UTC