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
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 = ...
Maybe something like this?
process : List(a), b -> c where
module(a).{ convert : List(a) -> c },
module(b).{ transform : b -> c },
hm
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()
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
so then every where is always followed by the keyword module
and if it's not, then you immediately have an error
That's how I wrote my examples in the gist https://gist.github.com/lukewilliamboswell/8aab3d62da2859b7dedbbe69fe0a895f
yeah the original idea was that you could either use that syntax or the where a.foo ... "method-style" syntax as a shorthand
but maybe the shorthand is not worth it, and it's better all around to only have one syntax for it
Any thoughts on this?
we may what to add braces to improve our error handling ability anyway.
I guess requiring the module solves this. If we see a newline then that keyword we know we're starting a new clause
If we see a where but not a module then that is also invalid. After the module is just a normal type annotation
module(a).<normal type annotation>
yeah exactly!
i still wonder if there is a conflict with nested wheres
around the comma delimiter
hm, what would be an example? :thinking:
Looks ok to me - but I'm not confident
Hash(a) : a
where
module(a).hash(hasher) -> hasher,
module(hasher).Hasher,
Does that look ok? I may have butchered the example
no nested
I'm sorry, I don't follow
# 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(),
(sorry on phone)
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.
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,
ok so no nesting
I'm just making these changes now in my Draft PR
Depending on how productive I am today, I hope to have Parsing and Can done for where clauses this afternoon
are you going to rewrite the parsing of this feature?
Yes
man my PR is getting out of date fast. hopefully i'll have time to actually work on it tomorrow or the day after
I don't understand what module(hasher).Hasher is specifically... I'm confused about what this means.
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.
I found the design referenced in static dispatch proposal. Reading up on it now.
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
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)
I'm thinking that makes a whole lot of sense
This would bring us back to the parsing problem we had earlier though
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.
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.
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)
I read it like it was sortable and now it isn't
I mean you could put sortable on both sides, I was just lazy
But yeah, it does lead to duplication of application which might be less clear
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
sort : List(Sortable(elem)) -> List(Sortable(elem))
It's a bit long, but I do like how well this reads
"give me a list of sortable elements and I give you back another list of sortable elements"
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
yeah any time you see a type variable like elem it should mean the same thing
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
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,
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)
yeah also let's try calling it Hashable
instead of Hash
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:
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
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.
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.
My understanding is that this is one scope because it is one type annotation.
So the type var elem is referring to the same thing everywhere in this annotation
Yeah, exactly, entire thing is one scope.
At least for type variables
Everything else like the B type can be referenced from the surrounding scope.
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
I see. So : inside of where clause reuses the scope. That makes sense
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]
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
Bis 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
I'm not sure that is valid. I think Concat(item) may be required....but not 100% sure either way.
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))
}
It was @Brendan Hansknecht finding, and the snippet looks valid
@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
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
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)
The Hasher(h) is a problem right? wouldn't the h need to be in the declaration.. so Hashable(elem, h)
Oh yeah....hmmm :thinking:
I see no problem. Hashable shouldn't be parametrized by hasher, only the hash function should
Similar logic with Concat from above
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()
}
Meaning you can use only MD5 to hash item?
Well in this function foo we have made the hasher type var concrete, 1 sec I'll give a generic example
bar : Hashable(elem, hasher), Hasher(hasher) -> U64
bar = |hashable_list, hasher| {
item.hash(hasher).complete()
}
Yeah, so elem and hasher are generic, right?
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
So h there is unified during application
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_
}
Yep! So you can do this:
z : ComplexNumber
z_hash_md5 = z.hash(md5).complete()
-- or that:
z_hash_sha1 = z.hash(sha1).complete()
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.
I don't think Concat : List(item), List(item) -> List(item) is permitted, it must be Concat(item) : List(item), List(item) -> List(item)
But that's how function type definitions work. Like, from where the function takes the item variable value? It's free variable:
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:
Thank you to Gemini 2.5. Pro for providing the following summary of the trade-offs between these two options.
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)
Formal Classification: This is Rank-1 Polymorphism. The type signature can be expressed with all universal quantifiers at the top level: ∀ elem, hasher. Hashable(elem, hasher). This is the form of polymorphism that the standard Hindley-Milner (HM) type system is designed to infer.
Type Inference and Unification: From a type-checking perspective, this is straightforward. When the Hashable(MyType, MD5) constraint is encountered, the type variables elem and hasher are eagerly instantiated with MyType and MD5 respectively. The compiler then simply needs to check if a hash function with the resulting monomorphic signature (MyType, MD5 -> MD5) exists in the appropriate module. The entire process is algorithmically simple and inference is decidable.
Practical Implications: This design forces the user to select the hasher type at the site where the Hashable constraint is used. This can lead to a phenomenon known as type parameter propagation, where the hasher type variable must be passed through multiple layers of function signatures, even if it is only used in a deeply nested function. This adds significant verbosity and can make APIs more rigid.
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)
Formal Classification: This is a Rank-2 Polymorphic Type. The universal quantifier for h is nested within the type signature, specifically to the left of a function arrow. The full type can be expressed as: ∀ elem. (module(elem).hash : (∀ h. (Hasher(h) => elem, h -> h))). The critical feature is that hash itself must be polymorphic.
Type Inference and Undecidability: Full type inference for higher-rank polymorphism is undecidable. A standard HM-based inference engine cannot automatically deduce a rank-2 type for a function without assistance. Therefore, for this design to be viable, the language must require an explicit type annotation from the programmer for the hash function. The compiler's role shifts from inference to checking the annotated type against the function's implementation.
Implementation Strategy: A type checker must be able to handle non-prenex quantifiers. When checking a function that uses a Hashable value, the hash method must be treated as a generic function. The common technique for this is skolemization: the compiler replaces the quantified variable h with a "rigid" or "skolem" type constant that cannot be unified with any other type variables in the scope. This ensures that the hash function is used in a truly generic way, as intended by its signature.
Expressive Power: This design is significantly more expressive. It correctly models the domain by decoupling the Hashable property of a type from any specific hashing algorithm. It is the foundation for implementing systems like Haskell's typeclasses and is essential for writing highly abstract, reusable code that avoids the verbosity of type parameter propagation.
But higher-rank is not higher-kinded? So we can afford it
we can't do higher-rank
https://www.roc-lang.org/faq#higher-rank-types
that said, I'm not sure we would need higher-rank to make this work
with just one type variable
e g. Hash already has one type variable today with Abilities
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
Richard Feldman said:
e g.
Hashalready has one type variable today with Abilities
Sorry, looking at that, I'm not sure what do you mean
I mean the Ability itself, not the hash function it requires
the hash function takes 2 arguments and they need different type variables
Yes, I don't see where the ability has a type variable at all besides functions. I must be missing something important
sorry, what I mean is that Abilities are not parameterized on multiple types
like today we say foo implements Hash - there's only one type variable involved in that, not 2
so I think it should be doable to maintain that when changing from Abilities to static dispatch
https://roc.zulipchat.com/#narrow/stream/304641-ideas/topic/where.20clause.20changes/near/527613907
So this should work, right?
I think we can make it work, yeah
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:
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
we might be able to relax that rule but I'm not sure
Yeah, I think what I missed before is that fucntions introduce their own type variables scope. As such, they can define new type variables
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)
This matches how current roc works with abilities
This is also valid in roc:
BinaryFn: elem, elem -> elem
myFn: BinaryFn
myFn = \a, b -> b
At least based on current roc rules these are all valid and I think they should remain valid
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
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
But it means HKT?
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
Why would it mean HKT?
I'm pretty sure it just is viewed as a new type variables from rocs perspective
Ah yeah, it doesn't mean elem is parametrizable after all
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.
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
The downside is nested applications I think Sortable(Hashable(item))
Lol, make another alias ... Shortable(elem) : Sortable(Hashable(item)) :laughter_tears:
Yeah, cause dict would be Hashable(Equatable(key))
seems fine to me
the main downside there is the long names, but no matter what syntax we use, you have to write out all of both names
nesting is both concise and obvious
I think the biggest oddity of nesting is if a type variable shows up many times
List(Equatable(elem)), List(Equatable(elem)) -> List(Equatable(elem))
Vs
List(Equatable(elem)), List(elem) -> List(elem)
Is the second legal?
If the first is required, then are we ok with potentially repeating the same thing many times instead of once?
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
the second one seems so confusing I think the compiler should complain about it
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
I agree with that
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.
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
Yeah, this is definitely my most preferred of the current options:
List(elem), List(elem) -> List(elem) where Equatable(elem)
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)
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)
Probably more options, but I'd use the ones above
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.
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
That is fine by me and yeah trailing comma sounds great
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
To put it simply, just looking at Hashable(elem) and BinFn(elem) - what's the difference between them?
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.
definitely on the functions I think
you need them on the functions at a minimum
so might as well have that be the only place you have to write them
So my big takeaway from this convo are the below:
My open questions:
Are my above takeaways correct?
I don't think we concluded that they should always have braces
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
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)
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
I don't think that design is ok
that's what I mean by the functions requiring it as a minimum
I think we should not sacrifice the design invariant that all constrains on type variables are visible in the type itself
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.
yeah I don't think types should hide that info
Ok
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
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.
hm, why? :thinking:
or maybe a better question is: what would it look like?
Cause Dict(Hashable(k), v), Hashable(k) -> v
Is not clearly adding static dispatch constraints unless you know the definition of Hashable.
good point!
As pointed out earlier, Hashable could be a normal type like List.
I think this conversation has convinced me we shouldn't do that syntax
and should instead put shorthands for where only in the where section
i agree
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
right, but I don't think we settled on that as the best solution, just one of the options :smile:
unless you special case where clauses and don't allow a trailing comma but the error case there sucks
e.g. we could say if you want more than one you need braces, but for one you don't
I don't think there was a problem with that design, was there?
That could work, but that's a weird inconsistency to me at least
Like it doesn't have any symmetry with anything else in the language
Binopable(fn) : fn where fn : elem, elem -> elem
Sorry :smile:
the thing is, I think having exactly 1 will be by far the most common thing to see in error messages
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
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]
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?
Could you please explain me what is shorthand here and what is not?
Like, can type aliases live in the same place with constraints?
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
(I'm saying "shorthand" because I don't think it's clear what we want the final name for these things to be yet)
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
yeah that last example, # mixed is the thing I think we need to figure out first
like what's a nice syntax for that?
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
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
If + looks weird, it can piggyback record extention syntax
I think we concluded earlier that all of these constraints need to go after the where keyword
and type variables need to stand alone in the main annotation body, nothing attached to them
oh I see, you're putting the constraints first instead of at the end
I absolutely cannot stand how Haskell does that and I don't want to do it in Roc :sweat_smile:
I think constraints go at the end, not at the beginning
I actually was inspired by rust. And when is optional here. You can still add it to push unimportant info back
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
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
Np, let me come up with how it could look like with where keyword
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)
})
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:
Could you share a snippet in concern?
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"
Shouldn't this work?
size : Dict(_, _) -> U32
Or what is size?
@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 : ...
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
that's why we started using the module(a) syntax
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
The interface (meaning "module interface") poposal also can keep Eq and Sort instead of having -able suffix
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
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
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
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 }
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]_ }
...
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" }
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.
I'm introducing :: as a new "interface declaration"
I'll create a gist with my proposal in nearest days then with motivations behind every point
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?
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)
}
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
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.
yes, that's the problem :smile:
1 sec
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
like self? ok, let me describe everything I have with the interface proposal in a separate doc
I strongly dislike magic type variable names like self
(the way it's used in Rust I mean)
or this in JavaScript
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
I'll use magic self and then we can figure out how it can be improved. wdyt?
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
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
which also means it needs to be able to coexist with the anonymous syntax in the same type
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
and it looks nice and is understandable etc.
I don't think any of the rest of the proposals are worth thinking about until we have that
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.
I've taken the long way around... but I feel like I finally understand the problem we're trying to solve now. :smiley:
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 }
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)
}
so here I'm saying "dispatch on the type variable from my annotation"
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)
but with that modification, yes, the a, b, c -> a where { module(a).insert : b, c -> a } looks right to me :thumbs_up:
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)
today, yes (setting aside #ideas > Do we need Num anymore?)
actually, sorry - I missed something
MD5.init is not static dispatch, that's just a plain old call to a function named init in the MD5 module
so it wouldn't be module(c).init : { seed: Num(d) } -> c
there just wouldn't be a constraint for that call bc it's not using static dispatch at all
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,
]
what does ImaginaryNumber.{ real, imag } mean? :raised:
Thats a nominal record - being de-structured into it's fields real and imag
ImaginaryNumber := { real: F64, imag: F64 }
let's not bring those into this topic :joy:
we have enough new stuff as it is!
in this discussion
Ohk, I that was already accepted from the Custom Types proposal.
yes, but we ran into implementation challenges that we didn't realize, so let's just not bring it up here :sweat_smile:
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 } }
that last example above is the thing I'm talking about
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
we need to settle on a syntax (this exact syntax could be one option, for example) for that specific case
and until we do that, I don't think it's particularly useful to discuss any of the other aspects of this syntax
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
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
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
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
pretty verbose too
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
which, in fairness, might turn out to be the way to go
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
so we can see what the commonalities actually are and what sugar might look nicer with htem
Here is another proposal https://gist.github.com/lukewilliamboswell/e244ded8b798c715d5376f566c5d17c7
Using [] square brackets to contain the set of constraints.
Almost identical to what we currently have
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.
what if you make both non-anonymous?
is it where [key.Eq, key.Hash]?
For comparison
Dict.insert : Dict(key, val), key, val -> Dict(key, val) where [
key.Eq,
key.Hash,
]
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]
Dict.insert : Dict(key, val), key, val -> Dict(key, val) where [
key.equals : key, key -> Bool,
key.hash : key, hasher -> hasher where [ hasher.Hasher ],
]
Don't ask me to make hasher anonymous too :sweat_smile:
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
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]),
]
this is my favorite of the syntaxes we've discussed so far!
:check: no parsing ambiguity
:check: anonymous constraints are clear
:check: non-anonymous constraints are concise
:check: mixed anonymous/non-anonymous looks nice
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)
,module(a). syntax:: :wink:I like it! Agree it’s both clear and concise for anon and non-anon
Here is all my notes update to this design (I think I got everything)
https://gist.github.com/lukewilliamboswell/913bd6e9ce2f7094eb4ae74cb8a903d1
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
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 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
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?
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?
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
Yes, insert/get and other function that use the whatever dispatched function should have it!
But IMO each function should have only the minimum where constraints necessary to do its work
Sounds like a good idea to have this restriction then!
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:
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
I see we open a can of worms here... cool!
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.
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
And then of course we need interface definitions.
@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?
I guess we could avoid this by doing:
interface Sortable : {
module(elem).order : (elem, elem) -> [LT, EQ, GT],
}
That also enables things like:
interface ReturnTypeDispatch : {
module(elem).generate : input_data -> elem where (input_data : Hash),
}
@Brendan Hansknecht the "Luke's proposal" Kiryl referred to is this
(for comparison)
I see
How is Hash defined in that proposal?
Also key.Hash still feels kinda odd to me.
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.
:: instead of a keyword like interface. I do like keywords in general, but in this case I like that we could have;: alias type declaration:= nominal type declaration:: interface type declarationwhere (elem : { order : (self, self) -> [LT, EQ, GT] }), this is why I tried the [ ] square brackets to visually distinguish from the annotation, avoiding () parens and {} as these are commonly used in type annotations. >> |dict, key| { dict.get(key) }
a, b -> c where (a : { get : (self, b) -> c })
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
yes
>> |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 ]
I'd like to hold off on discussing declaration syntax, so we can focus on type annotation syntax
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:
I think they should be tied some. I really hate seeing Sort(elem) at declaration and elem.Sort in type annotations
It feels inconsistent
we can always bring up consistency in the other thread
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
no, it is static dispach
record functions require extra parens
(dict.get)(key) <- this is a record
Til
yeah, that was an old debate
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.
elem.Sort
Why not module(elem) : Sort?
I would be happy with that too.
I think both proposal have ok overall syntax
Could even go as far as elem : Sort technically.
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
since everything in where clause constraints modules I think module(elem).order can be replaced with elem.order as well
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
Otherwise, I think the rest of Luke's where clause syntax looks good.
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]),
]
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
In inference you mean?
See
module is never needed. It is just a lot clearer.
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 ]
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.
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.
Wait... does this work?
>> |bytes| module(elem).decode(bytes)
Here elem would have been in the return type...
Ofc, as I said, everything inside of where clause constraints modules, not types
What is elem @Luke Boswell
>> |bytes| module(elem).decode(bytes)
I don't think that works in any system
It needs a type defintion
from_json : List(U8) -> Result(elem, [DecodeErr]) where [
elem.decode : List(U8) -> Result(elem, [DecodeErr]),
]
>> |bytes| module(bytes).decode(bytes)
a -> b where [a.decode: a -> b ]
>> |bytes| module(elem).decode(bytes)
a -> b where [ module(elem).decode : a -> b ]
:point_up: is broken.
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
Otherwise it violates the main HM principle of full inference without type annotations
See also
Could you please give an example in code?
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
That type doesn't make sense. You didn't pass an argument for c to decode.
The module(...) works on a type var
Yes, and it has no sense
It can make sense but only with a user defined type
I don't think module(elem) can be anonymous like that
Just like today when you use decode, the output type has to be concrete
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
Fundamentally, we want to support (or something roughly equivalent):
| bytes | {
result : Result a err = module(a).decode(bytes)
result
}
But I think it requires something to have an explicit user type annotation to anchor the type variable.
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
Aha. That's the Richard's pain point
Yeah, we still want decode to work.
So we need a syntax for return type based dispatch
I think what I wrote above is the current idea (or roughly close)
I really don't like how it brings type var into my beloved pure hm inference
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
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)
Inferred type
>> | helpers, bytes | (helpers.decode)(bytes)
{ decode : a -> b }, a -> b
Need to try in a real world scenario
Or maybe even allow passing modules as records :man_shrugging: fn(U8, bytes)
If you consider module a compile time function, we have it no matter what with static dispatch
It just is hidden in the standard case
a.insert(b) is module(type(a)).insert(a,b)
I get that it has an anchoring variable, but from the compiler perspective they are the same
Clearly return type dispatch (as with all other static dispatch) could be implemented with lambdas.
Really, I think the only current use case for this is decode
The biggest problem with decode via lambdas is the function signature won't be consistent
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.
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
Comp time function or not I don't think is relevant. I would not call module a function at all.
Anyway, yeah, it is a subsection of roc that requires types
One way around it is to force use of an interface
But that is not a syntax we support currently and would feel a lot more like current abilites
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.
Then the type system would know it was doing dispatch of the ok type of the result returned
What are folks thought on that instead of ever putting module(elem) directly in code?
CC: @Luke Boswell @Richard Feldman @Kiryl Dziamura
This means a for return type dispatch, you must always define an interface
sounds like a massive downside...what's the upside? :sweat_smile:
like now it's no longer structural ad-hoc polymorphism, which was a selling point
it's become nominal
and for what?
you're still forced to write a type annotation and then refer to it
Cause return type dispatch is already kinda broken and requires typing anyway
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?
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
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
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
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
for example, obviously if the type var is in the first argument position, we already have a syntax for that
but we don't have a syntax for if the type var happens to be in the return type position
a challenge is that type vars can be in a lot of positions
first arg and return var are just the tip of a gigantic iceberg
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
If you think module(type_var) is required, I definitely don't think we should support extra syntaxes for it
I can't see how you can be not explicit about specific var :thinking:
nvm
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
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
so I like the balance of:
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"
Anyway, I'm happy with module(type_var) it will be pretty rare anyway.
as in, you could write it with just a plain lambda that has no names involved
e.g. you can do this today if you want:
(|_| 1 + 1).foo(42)
(I don't know why you'd want to, but you can)
The upside of the design Brendan suggests is that you think it terms of constraints and not the type of the particular use case
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
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
oh it's not no names
my point is that they both have names
Ah
And you agree names are required here?
I don't think they're innately required, just that the language complexity cost of preventing names seems prohibitive
Sure
Ok. Same page
and I think if we are going to require names, it's better to maintain the structural ad-hoc polymorphism design
(as in, you don't have to define an Ability - or similar - up front)
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.
Super duper oneliner
|bytes| module([a.decode : _ -> Result(a, _)]).decode(bytes)
That.... Is something.....maybe we don't always need one liners.
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.
But it's too verbose ofc
Also, why not sigil? (Sorry) @module since it's a special thing. I don't care but have to ask
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.
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
Kiryl Dziamura said:
Also, why not sigil? (Sorry)
@modulesince 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:
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?
Type error I think
I'm failing to mentally desugar what "either definition above" would actually be here :sweat_smile:
can you write out the exact thing that would go in the repl?
Brendan Hansknecht said:
|bytes| res : Result ok err res = module(ok).decode(bytes) res
Kiryl Dziamura said:
Super duper oneliner
|bytes| module([a.decode : _ -> Result(a, _)]).decode(bytes)
It would work the same way as Brendan's snippet
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?
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?
But it's not a problem with the module. Like, it's unrelated
The type of the function we're talking about is fn : List(U8) -> a. How would a fn with the type act in repl?
I think the way decoding works in Roc is a major selling point of the language
the way I think about it is that whatever design we end up with has a hard requirement of continuing to support that :smile:
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.
what's an example of where you would need to define the type?
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
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.
that's always been true
it's true with Abilities too
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:
at any rate, we'll certainly need it again!
Oh, I just mean it will be common place now
Cause static dispatch is the default way to interact with types
I know it could happen in the old compiler, but it was rare in comparison
So minor ergonomics loss requiring typing more often
ah I see
I guess theoretically? haha
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)
which wouldn't be any different
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
so I'm not worried about it being a concern in practice, but I guess we'll see!
Should module interfaces (or whatever we would call them) work like traits? E.g.
|bytes| Decode.decode(bytes)
I brought this up earlier and the preferred answer was no.
I should reread the convo. It feels so natural. Num.add(a, b), Addable.add(a, b)
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?
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:
like certainly |a| B.c(a) is valid Roc code, so why wouldn't |bytes| Decode.decode(bytes) work?
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
ah :+1:
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 *
so we'd need some way to bind it, e.g.
|bytes|
module(val -> { decode : List(U8) -> Result(val, Err) }).decode(bytes)
Isn't _ just a or b or an arbitrary type variable?
Also, what do you mean by "bound"? Like we don't know it is what needs to be dispatched on?
Free variable. But it can be inferred, I see no problems. At least in this particular place
Ah.. I see
But in the original oneliner it was bound: a.decode
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
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
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
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]
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 = ...
At least until this thing we're talking about here is fully defined/implemented!
Luke Boswell said:
One thing that doesn't quite sit right with me is that
module(a)is sayingamust 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]),
]
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]
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
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)
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!
It's kind of so obvious I can't believe we didn't think of that before
I like it
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
Richard Feldman said:
and here's a proposed syntax for defining the
wherealiases: (not sure what to call them yet)val.Eq : [val.module.equals : val, val -> Bool]
They are "constraints" right?
yeah sometimes it takes awhile for an obvious design to appear :joy:
As in literally these are the constraints used in the HM type inference algorithm
they are constraints, but so are lots of things
that said, "type alias" and "constraint alias" could be an interesting split :thinking:
"so are lots of things" -- I'm curious what else is?
a -> a is a constraint - both arg and return type are constrained to be the same type
I looked at that and just thought it was an alias... just without a "type" annotation part
right haha
Like all type annotations are constraints, and a type alias has the general shape <header> : <type> <clauses>
val.Eq : {} where [val.module.equals : val, val -> Bool] -- is this unnecessarily verbose? not sure it even makes sense
I like this syntax. Just wait for the module function though
document.module.module : ...
Last updated: Jun 16 2026 at 16:19 UTC