I recently proposed having warnings for unannotated exposed symbols in a thread as a way to shore up a concern on the interaction of type inference and static dispatch. Namely, current Roc qualifies most function calls with the module that they're from (e.g. List), which makes both reading what type is being used as well as inferring a human-readable type annotation quite easy. This is less true in Static Dispatch Roc:tm:: taking the example from the linked thread, something as simple as this:
count_valid_elements = elems ->
elems.map_try(.validate()).len()
Would infer a pretty complex type signature without any type guidance:
count_valid_elements : a -> e
where
a.map_try(b -> c) -> d,
b.validate() -> c,
d.len() -> e
count_valid_elements = elems ->
elems.map_try(.validate()).len()
I want to find a mechanism that helps devs get good autocomplete suggestions and type as they go without needing to plan too far ahead. My thought is that just by constraining the argument types of the count_valid_elements function, we can get pretty good autocomplete on map_try and len:
count_valid_elements : List(Foo) -> _
count_valid_elements = elems ->
elems.map_try(.validate()).len()
Even if we only annotated the arg type as List(_), we'd still get map_try and len autocompleted, just not .validate. And of course, a reader for these functions can also generally infer what's happening even without a return type.
Hence why I want to consider a syntax that enables people to get this context without going out of their way, maybe that will make it more likely to happen! I can think of quite a few languages that have a syntax for this...
It's (optional) inline type annotations!
count_valid_elements = |elems: List(Foo)|
elems.map_try(.validate()).len()
We're not all totally onboard with using | for function arg delimiting, but if we do, I think this quite naturally falls out of that. Users can now optionally write arg types as they go and get a significantly better experience for it. I really think that without type annotations on the args (at least), a lot of the static dispatch benefits are lost.
Let's see some other examples and then talk about why they look the way they do:
simple_func = |arg1: Str| -> List(U8)
arg1.to_utf8()
effectful! = |text: Str| => {}
Stdout.line! text
complex_func = |{ left, right }: Pair, Unwrap(inner) as outer: TagUnion| -> OtherTag(a, b)
where a.foo() -> b, b.bar() -> List(Str)
# required newline after multiline type signatures
combo: U64 = left + right
var loop_var_: List(Str) = [inner]
for item: Str in more_items do
loop_var_ = loop_var_.append(item)
OutputTag(loop_var_)
# this is not as nice, but still allows breaking up typing
other_complex_func = |
{ left, right }: Pair,
Unwrap(inner) as outer: TagUnion,
| -> OtherTag(a, b)
where a.foo() -> b, b.bar() -> List(Str)
function.body().below().newline()
# I think we disallow annotating return types in inline function bodies
inline = |val: U64| -> U64; val + 64
A few nice things about this:
-> and => demarking effectfulness in type annotations but not in function bodies_ in func : List(Str) -> _ that we'd need to do with our current type annotations to get this benefitI think that there's some wiggle room on the syntax here that I'm open to discuss, but I'm mostly here to see if people agree that this pushes people towards better dev/reading experience.
A few things I'd like to hear from you all if you don't like this proposal:
The where clause is very strange with this syntax
It's almost exactly the same as where in Rust
Yes, but it's in the function body
Cause we don't have braces
Also, in rust you often use a trait directly in the type annotation which reduces how often where is used.
Yeah, that's why I have the mandatory newline. As is demonstrated by the |arg| -> U64; arg + 123 example, the boundary between the return type and the function body is IMO the weakest part of this proposal
Brendan Hansknecht said:
Also, in rust you often use a trait directly in the type annotation which reduces how often where is used.
Sounds like we could just emulate impl Trait from Rust to help with this issue:
func = |arg: is a.foo() -> Str|
arg.foo()
Anyway, I'm pretty neutral to this. I think the separate line is a lot cleaner to read still.
I do agree that static dispatch is more complex without type info
Kinda blind
But I also think that rust function definitions with types are a mess and don't really want them in roc
Personally if a function has any sort of vague complexity, I type first then write the body.
I think Python has the exact same story here for type annotations and autocomplete. If you write:
def to_uppercase(s):
return s.up...
And your cursor is where the ... is, you get no autocomplete to .upper(), which is why type hints help with that:
def to_uppercase(s: str):
return s.up...
This will autocomplete to upper() from my memory
I think this is something where we can (and should) wait and see if it's a problem in practice before considering taking action on it
might turn out to be totally fine! :big_smile:
I'd be surprised, but I'm open to the possibility
Would infer a pretty complex type signature without any type guidance
I see this as a positive thing. People who think in types and write annotations are given more useful assistance from the compiler... therefore people will be more inclined to do that for any nontrivial type.
I like that type annotations are completely optional, because there are times when you don't care. Maybe you're still thinking, or maybe it's a quick and dirty to throw away, or maybe it's just plain obvious.
helps devs get good autocomplete suggestions and type as they go without needing to plan too far ahead
I also think this is a good thing. People are less likely to chain massive things together because it's harder to follow... so they will break it up and give things names (and hopefully annotations).
Even if we only annotated the arg type as List(_),
It's really nice adding even a partial annotation, even leaving holes everywhere is helpful. This encourages people to do that, and also not get to hung up on trying to write out the full type because we know with full inference the compiler can get what were talking about.
count_valid_elements = |elems: List(Foo)|
What I don't like about the inline annotation is that now it mixes in the types and makes things more verbose. Maybe for a simple List a it's not too cluttered, but it quickly get's wild. Why not just make an annotation on another line at this point?
I like that with |_| the -> and => are relegated to "type" level information.
Do you agree with me that non-annotated function args make the autocomplete experience worse?
Yes, but I think think this is why people who want nice autocomplete will naturally begin to think in types, and use annotations for anything that is not trivial or quick and dirty.
what other tools do we have available that you would like to see combat this issue?
I guess I disagree that this is an issue, at least in the same way... but I don't fully understand the interaction with language tooling.
How bad is the autocomplete if I'm at this point and hit .?
count_valid_elements : List(_) -> _
count_valid_elements = |elems|
elems.
Basically I think the separate line for type annotations has really grown on me, and I've come to really appreciate it.
Note, an lsp code action should be able to generate a placeholder all underscore type automatically:
count_valid_elements : _ -> _
And set the cursor at the first underscore to fill it in
So at that point it is really no different on ergonomics than the proposed
You're right that if you have autocomplete from an LSP, you also have code actions
So we can plan to have code actions that might, for example, let you type count_valid_elements and then run "Add annotation" that adds Brendan's suggested skeleton, and your cursor would get moved right to the first argument's type hole.
I'd be much more likely to write count_valid_elements = |elems| and then think "I need a type" and copy the name of count_valid_elements and then write it, which is a bit more execution function required than just writing the type right after the arg, but it's not that much
I guess I disagree that this is an issue, at least in the same way... but I don't fully understand the interaction with language tooling.
@Luke Boswell when I said "what other tools do we have" I was talking about syntax, typechecking, or LSP, not just code actions. Generally asking what can be done to get good autocomplete
Yeah, this whole discussion comes down to either:
I'm happy to wait for that to bear out, though it is somewhat "syntax explosion season" right now, meaning changing things now would cause fewer disruptions later
My first question, @Sam Mohr , is why are you such a bad person? :joy::rolling_on_the_floor_laughing::joy::rolling_on_the_floor_laughing:
I spend my time here not to improve Roc, but to break @Luke Boswell 's hopes and dreams of a better state of programming languages
I think this fits well with the recent threads poking at “what will we accept syntactically to find mainstream acceptance?” proposals. I don’t think this would be good or bad - but man it’s getting harder and harder to find the Elm lineage in the language as we go on.
the APIs are still straight outta Elm
same as always
True
the only substantial change there has been Task becoming =>
It’s just the vibes are changing fast :joy:
which has been one of the most well-received changes in the history of the language :big_smile:
Oh I mean with the proposed changes
Not what’s actually happened
I don't think that'll change the APIs much either
I think in my brain some of this has happened
well take Str as an example
when static dispatch lands, off the top of my head I think the only changes would be:
Str.equals, Str.hash, etc.)that's probably it?
same with List
Until the next big :light_bulb:
Side note, equals is better than is_eq
I actually think this is gonna be what v0.1.0 looks like
I have really appreciated how (correction Richard) keep discovering nice ways to simplify the language
** my contribution is just throwing rocks
maybe I should start a thread about that
Maybe a blog post?
That sounds like a big commitment though
actually I don't think blog posts are big commitments
:grinning_face_with_smiling_eyes:
So you have no worries about the types that will be inferred for these SD-using functions? I’m trying to understand how - absent annotation - we could even supply this wonderful autocomplete experience we imagine with SD.
well going back to this example:
count_valid_elements = |elems|
elems.map_try(.validate()).len()
certainly you can't get it to autocomplete .validate without giving the compiler more information than what's in that snippet
because it can only possibly work with the information that it has
if you've already written code that calls count_valid_elements (before you implemented it), that can do it
or if you add a type annotation, that can also do it
given that it's about as much work to write : List Elem as it is to write .validate() without the assistance of autocomplete, and that LLM-powered autocomplete can (some of the time) guess implementations based on surrounding context even in the absence of type information, I'm just not sure that in practice people will be like "yeah if I had that I would use it and it would save me time"
maybe people will say "hey I wish this existed so I could use it" in practice, in which case we can talk about specific scenarios that have come up for people in practice
but to me it seems premature to talk about solving a problem which is so hypothetical at this point
That’s fair, but I’d like to understand how we would even autocomplete map_try? elems is an a and we just try matching it to all methods from all available modules that contain opaque types? And the once one method has been used, all chained methods are easy?
Is that why you think .validate is the harder case?
Would you have to type m a p ... etc and it reduces that list down?
Anthony Bullard said:
That’s fair, but I’d like to understand how we would even autocomplete map_try? elems is an
a
oh, sure - we also wouldn't be able to infer map_try if you were writing jut that from scratch with no annotations
Anthony Bullard said:
we just try matching it to all methods from all available modules that contain opaque types?
we could certainly do that if desired
we know which modules are in scope, and we know what all their exposed types are
so yeah, we couldn't narrow it all the way down to just the List ones but if you type m, we could make it so that map_try (from List) is one of the ones you see in the autocomplete list
I'm strongly in favour of inline annotations. I have often thought while writing roc that my experience would be significantly improved by having access to them.
My number one roc frustration is weird type mismatches which propagate to far off places. Almost always to fix that I want to hop around my program "pinning" types here and there when I know what they will be.
Currently in roc that process is really annoying. It's not really even possible in pattern matching, it feels super verbose for individual variables, It isn't possible for anonymous lambdas (which are very often the place I want to do this btw).
So I'm strongly in favour, mostly because I think it improves the debugging experience for what I think of as roc's most uniquely frustrating issue.
sarcasm... If a lambda has no name, does it deserve to have types?
Hahah. Well that's it right, I generally don't want to write a whole type. I just want to type a single arg.
#.. some chain of pipes
|>List.map \ {a,b}:{a:Str, b:[Yeah, Nahh]} ->
Like all I want to do is basically say the type system:
"alright I know my type should look like this:, don't go running off union tags and records and producing some ungodly mess of a type error later on, just tell me if I'm wrong right here"
The first good person in this thread!
I like typing things. I just think inline types look pretty terrible.
And I live just fine in roc without them so feel no need for them
Yeah, I think my want for this feature comes down to incentivizing Roc devs to improve readability, but disciplined devs don't benefit as much
Given roc infers all types, I don't worry too much about what other devs do. In any code base that grows I think that annotating top levels will become a pretty natural lint.
I'm thinking about reading code in PRs and on GitHub without an LSP. All of my concerns are mostly assuaged with tooling, but it's not great if an LSP is required to read someone's fully type-inferred code
Brendan Hansknecht said:
I like typing things. I just think inline types look pretty terrible.
Well I think types on their own line look terrible.
So there! :joy:
I'm averse to redundancy which makes me dislike typing the name of the def twice, but I see the benefit of keeping the body of the def cleaner, so I'm not sure which I visually prefer
I think the multi line is fine in top level Defs but just really ugly and messy when used inside a function body or to annotate a single variable or soemthing..
Yeah, I can agree with that one.....honestly, I think c style is the cleanest inline
As much as I try to love x : Type = ... I think Type x = ... is just less noisy inline.
Also, a lot of languages have less and less types inside of functions locally. But I think they all essentially always type function and lambda args (which is probably the more important piece of complexity here)
Like in rust, it is just let x = ... with no type. In c++ you see more and more auto x = ...
So clearly there is a more complex balance here
I mean if the only issue is the extra :, many languages like v allow you to drop it x Type = and IMO it reads perfectly fine (I especially love it for record types).
I agree with @lue here, I think Go syntax is great, and even better for a language with optional annotations.
And with PNC syntax, it fits in
Here's Sam's original code sample with Go-style type annotation:
simple_func = |arg1 Str| -> List(U8)
arg1.to_utf8()
effectful! = |text Str| => {}
Stdout.line! text
complex_func = |{ left, right } Pair, Unwrap(inner) as outer TagUnion| -> OtherTag(a, b)
where a.foo() -> b, b.bar() -> List(Str)
# required newline after multiline type signatures
combo U64 = left + right
var loop_var_ List(Str) = [inner]
for item Str in more_items do
loop_var_ = loop_var_.append(item)
OutputTag(loop_var_)
# this is not as nice, but still allows breaking up typing
other_complex_func = |
{ left, right } Pair,
Unwrap(inner) as outer TagUnion,
| -> OtherTag(a, b)
where a.foo() -> b, b.bar() -> List(Str)
function.body().below().newline()
# I think we disallow annotating return types in inline function bodies
inline = |val U64| -> U64; val + 64
Eli Dowling said:
My number one roc frustration is weird type mismatches which propagate to far off places. Almost always to fix that I want to hop around my program "pinning" types here and there when I know what they will be.
Currently in roc that process is really annoying. It's not really even possible in pattern matching, it feels super verbose for individual variables, It isn't possible for anonymous lambdas (which are very often the place I want to do this btw).
I'm curious to learn more about this! Do you think type hints in the editor would help, if they revealed inline what types the compiler thinks things are?
Also I'm curious how often you have top-level type annotations when you're encountering this.
as an aside, a downside of inline annotations is that it would create two competing ways to annotate top-level functions. So this would become syntactically valid:
foo : Str -> Str
foo = |arg : Bool| -> Bool
...
...and then you could have a new category of error, namely that the two annotations for the same thing disagree :sweat_smile:
The same error could happen even if we switch to only having inline definitions, if there are multiple places that you can put the type:
# typing the foo pattern, and also typing the function definition
# this is more likely to come up if you have an alias for the function type, like `foo : MyPredicate = ...`
foo : Str -> Str = |arg : Bool| -> Bool
...
# typing the a and b subpatterns, and also typing the record as a whole
{a : Str, b : Str} : {a: Bool, b: Bool} = ...
I don't think we should try to come up with strict rules about which parts of a pattern can get types, so I think this is just an inherent tradeoff with inline types. But I do still like them, and think they could be worth allowing even with the extra way to trigger a type error.
If you have a top level annotation, in what situations would you need to annotate a local binding or pattern? (I'd like to just a list of problematic scenarios).
Brendan Hansknecht said:
As much as I try to love
x : Type = ...I thinkType x = ...is just less noisy inline.
One thing I dislike about Type x is that variable names don't start at the same column. When I'm parsing the code quickly and want to get rough idea of what code is doing I'm first looking for variable names and see types as 'details'.
String name = ""
UInt32 age = 27
List<ChatMessage> messages = .....
Decimal account_balance = ...
vs
name String = ""
age UInt32 = 27
messages List<ChatMessage> = .....
account_balance Decimal = ....
For me second variant is DRASTICALLY easier to understand. Usually variable names are pretty descriptive so you get what's going on by just looking at them and if they're always starting on first column of the text it's just perfect for me.
Types really are just implementation details, today age is UInt32, tomorrow it will be UInt8 and as such I think they should come after variable name.
Another problem with Type x = ... is that in today's syntax it's ambiguous with Tag x = .... (But x Type = ... works.)
Richard Feldman said:
I'm curious to learn more about this! Do you think type hints in the editor would help, if they revealed inline what types the compiler thinks things are?
Also I'm curious how often you have top-level type annotations when you're encountering this.
I've started compiling a list of specific examples of bad and confusing type errors I encounter in the wild and I will report back.
Yes I definitely think editor annotations would help, but we would also have to solve the current issue where type incompatibilities aren't propagated to the lsp as anything other than <type mismatch>. But if we could figure out a way to display type mismatches in a useful way within the editor that would definitely help a lot.
I definitely thought as I was writing, that maybe improvements in tooling and the compiler would also solve the issue.
But also I like inline annotations so still for this regardless :sweat_smile:
As for too level annotations, I tend to not have many. To be fair most of my roc code is exploratory programming where I'd really rather not constrain things though.
Part of my lack of top level annotations is actually that I find it really annoying to rewrite the signature and name and put in all the _ for stuff I don't care about like error accumulation. It breaks my happy flow.
I think "a should be a string"
And instead of pulling up and writing :Str
Now I'm wrangling with how many arguments the function has and whether it's effectful, and so now I've got to add
my_func_name: _, _, Str, _ -> _
And my variable names don't line up with my annotations so I have to play a little counting game to make sure my annotation is on the right argument and it's all just a bit nasty. Most of this struggle would be solved with an "annotate" code action but I think inline annotations solves it more cleanly.
I think inline type annotation would generally make me much more likely to pepper little annotations through my code, as I often do in ocaml and fsharp
For me second variant is DRASTICALLY easier to understand. Usually variable names are pretty descriptive so you get what's going on by just looking at them and if they're always starting on first column of the text it's just perfect for me.
To clarify, 99% of the time, my preference is no types inline (except if I make a named lambda). So just the name is all I want. If I want types, it is cause I think the name is not/can not be clear. Or for a very temporary moment due to be confused by types (very very rare if all functions are typed).
I really like the idea of at least optionally allowing arg types to be annotated without having to duplicate the function name.
However, this syntax will have some parsing problems:
foo : Str -> Str = |arg : Bool| -> Bool
...
We'll have a really hard time distinguishing between the cases where the return type is multi-line, and the start of the body. Both cases would naturally be indented by 4 spaces relative to the start of the previous line. Maybe you're suggesting we disallow multi-line return types in this syntax? Or maybe if they are multi-line, they must be put in parens?
Alternatively, the arrow could move to after the type, e.g.:
foo : Str -> Str = |arg : Bool|: Bool ->
...
I didn't think there would be ambiguity here since || syntax is only used in implementation, not in the type signature (and there is no -> with || syntax here it would mean "next parse a return type" unambiguously).
Ahhh sorry; I messed up my example a bit. Also possible I didn't understand the proposal.
I think you'd either see
foo : Str -> U64 = |arg| arg.toUtf8().len()
...
or
foo = |arg: Str| -> U64
arg.toUtf8().len()
...
or (arrow free):
foo = |arg: Str|: U64
arg.toUtf8().len()
...
Right, so in your second example, after we've parsed U64, and the parser sees a newline, indent, and then arg, how does it know whether that's a continuation of the type (so U64 arg), or the start of the body?
Yeah, I see your point. It does seem that multiline signatures would require parens if there is not a sigil for introducing the body of the function
Yeah, I don't love it. But how common are multiline return types?
Let's look at osmething like Result.mathBoth
Which today is
mapBoth :
Result ok1 err1,
(ok1 -> ok2),
(err1 -> err2)
-> Result ok2 err2
Joshua Warner said:
Right, so in your second example, after we've parsed
U64, and the parser sees a newline, indent, and thenarg, how does it know whether that's a continuation of the type (soU64 arg), or the start of the body?
I think the new PNC type world (types are Dict(Str, U64)) could only have the return type of a function followed by a where clause, so if "where" shows up, then you keep going, otherwise it's the function body
I can't personally think of an ambiguous case
If you had inline annotations, it would be either(in proposed syntax)
map_both :
Result(ok1, err1),
(ok1 -> ok2),
(err1 -> err2)
-> Result(ok2, err2) = |res, ok_mapper, err_mapper|
# ...
or
map_both = |res: Result(ok1, err1), ok_mapper: (ok1 -> ok2), err_mapper: (err1 -> err2)|: Result(ok2, err2)
# ...
Which would be probably formatted as:
map_both = |
res: Result(ok1, err1),
ok_mapper: (ok1 -> ok2),
err_mapper: (err1 -> err2)|: Result(ok2, err2)
# ...
Does this mean that inline annotations have to either be solely on the def itself ORR on the args and return value?
Because
map_both :
Result(ok1, err1),
(ok1 -> ok2),
(err1 -> err2)
-> Result(ok2, err2) = |
res: Result(ok1, err1),
ok_mapper: (ok1 -> ok2),
err_mapper: (err1 -> err2)|: Result(ok2, err2)
# ...
Seems insane :rofl:
UPDATED TO USE PNC SYNTAX FOR TYPES
@Sam Mohr Oh yeah, I forgot with PNC we don't have space application anymore
Everything is now "enclosed by law" :smile:
Ah yeah if types no longer have a "space-separated apply" syntax, this isn't an issue
I really think this shouldn't be optional..I think having inline and out of line options is a mistake and just messy. I think we should have only one or the other
Of which, I much prefer out of line
That was my intention when I proposed this
Only inline types
(If we took this option)
Yeah, I think only inline would be much better than a mix but of both, but clearly still lean only out of line.
Especially with the knowledge that for loops are coming and deeply nested lambdas will be less and less likely with all the coming features.
It seems like we would need some benefit that inline gives that can't be gotten from header type annotations, or for more than 50% of people to want it. Otherwise we should stick with the default of our current approach
Note: I think after our future syntax stabilizes seeing more code in practice will really tell how much impact an inline annotations might have. I think it heavily depends on the number of inline lambdas in roc (specifically lambdas where the types aren't obvious). I think those will be pretty rare. If they are not rare, then inline will make way more sense.
Well, I still fail to see how any functions will properly take advantage of static dispatch without people annotating their arg types, which is what this helps with
But I'm planning on pushing for some "infer type holes" LSP code action that means you can throw a func : List(U64) -> _ on top and then fix the underscore later
There are two things that I find non-ideal about out-of-line type annotations:
That said, 99% of my experience comes from languages with in-line type annotations, so maybe I just haven't given out-of-line a fair shake.
At this point, I'd be strongly in favor of switching to inline type annotations.
I think out-of-line is a really valuable tool for focus in a language based around pure functions
Hmm, say more?
a really common thing that would happen when I would talk to Evan about API design is that I'd start explaining how everything would fit together, and he'd stop me and say "just tell me the type"
here's an example, pulled at random from some code I'm writing right now:
Level, (Level, Str => {}) -> Logger
this is the type of a function
can you guess what it does just from the type, without knowing the names of any of the arguments or of the function itself?
Yep! Fair point
that's what I mean about it being a valuable focus tool - I think it's not only helpful to think in terms of the types alone, it's actually the most helpful way to think about how to put a program together
like "what are the inputs, what are the outputs"
and in the case of -> vs => the binary of "does it produce side effects or not?"
and the thing I don't like about inline annotations is that they make it harder to see the "shape" of functions
which I think is the really important part - what goes in, what comes out, etc.
I think this is less true in a language with unrestricted side effects, because so many functions are like "well yes, but also there are inputs that aren't mentioned here - namely that this goes and reads from the database, but you'd have no way of knowing that by looking at the type!"
so I can't really think about programs in quite the same way, or at least I can't focus on the types in the same way (in, say, Rust compared to Roc or Elm or Haskell)
As a counter-point, here's a rough translation of the type of a (rust!) function I was working on recently. I'd still consider this a valid-ish example, since this function is written in a functional style (no mutation of args)
Bump, State, (EExpr, Position -> e), ExprParseOptions, u32, Loc(List CommentOrNewline), (Position -> e) -> ParseResult (List (SpacesBefore (LocStmt))) e
yeah I mean in Roc I'd prefer to put that in a record
once the number of arguments gets past a certain point, I'm increasingly likely to do that
I'd do it more often in Rust except Rust doesn't have anonymous records, which is annoying :stuck_out_tongue:
but if you think about it, a Rust function with inline annotations looks a lot like a Roc function that takes a record
Ahhh!
except that the Roc one has an extra { and } around the args
(at least the type)
and then I specify the arguments with labels instead of positionally - which I actually prefer once there are that many args :big_smile:
Yep yep, that makes sense
Richard Feldman said:
he'd stop me and say "just tell me the type"
I'd like to push back on this. I think this attitude can also be what leads to FP languages having very beginner-unfriendly APIs.
Like yeah I think I can figure out what that is, but if you instead had showed
createLogger= | minLevel:Level, printer: Level, Str => {} |: Logger
crash
It would be way faster for me to understand.
I find the "just look at the function type" is a trap people can get into when they're already in the domain and know what things are supposed to do. I think it can actually be quite opaque to newcomers. Particularly coming from languages with less emphasis on expressive type systems
A great example of this is that language servers for functional languages often don't include function parameter names in its type signature when hovering. This is fine if you "just read the type". I think it's crap, makes it harder to figure out what a function does and is strictly worse.
I've certainly used functional libraries that just absolutely atrocious parameter names for their functions and I'm sure coming from Haskell you have too. I worry demphasising the importance of parameter names can lead to that.
All of that is significantly why I'm strongly pro inline types.
I'd like to push back on this. I think this attitude can also be what leads to FP languages having very beginner-unfriendly APIs.
I know the kind of APIs you refer too, I've definitely seen that in Haskell. But I don't think that sort of design sensibility is inherent to doing type-driven API design, or it would have shown up in Elm as well.
I find the "just look at the function type" is a trap people can get into when they're already in the domain and know what things are supposed to do. I think it can actually be quite opaque to newcomers. Particularly coming from languages with less emphasis on expressive type systems
This makes total sense to me when we're talking about documentation for packages/platforms. Types are not enough, there should be good doc comments with examples as well.
For designing new APIs I find using type annotations to sketch out ideas super useful, when I'm working by myself or in groups.
Just to be clear I'm definitely not suggesting sketching out APIs with just bare functions without any implementation is a bad idea.
I'm more suggesting I think also having parameter names is either neutral or better than just types.
Strong agree here
And also that culturally, thinking just types is enough, i think, can lead to less understandable APIs
Maybe not in elm, but certainly in other FP languages I've read code in.
Yeah, that's a good way to put it. Elm doesn't really have the culture of types being enough in the broad sense, that might explain part of the difference with Haskell.
Also, to provide a concrete example.
If Richard showed me this logger builder function signature:
Level, (Level, Str => {}) -> Logger
I would immediately ask for parameter names.
The first parameter is probably a log level cutoff right?
But I don't know that, maybe it's a minimum level, maybe a maximum, maybe the logger will only log at that level. Sure I can make an assumption, but in that case a parameter name would immediately make our communication more clear
Or maybe I'm wrong about that entirely which would be hilarious....
you correctly inferred what the types meant :big_smile:
I'm +1 that adding some param names is in the worst, neutral.
logger : | threshold : Level, handler : ( Level, Str => {} ) | -> Logger
logger : Level, ( Level, Str -> {} ) -> Logger
IMO the top level one is much clearer about the intents... if defining just that (no implementation) still works as usual, I'm mostly onboard with inline type annotations.
I think it loses readability on really small scenarios with implementation:
sum : | a : Num, b : Num | -> Num = a + b
# vs
sum : Num, Num -> Num
sum = | a, b | a + b
Just did a little thought experiment here - maybe it should be in its own thread but here it is.
Premises
# Impl. only
sum = | a, b | a + b
# Type only
sum = | a: Num, b: Num | -> Num
# Type + Impl
sum : | a: Num, b: Num | -> Num
sum = | a, b | a + b
Experiment 1:
To prevent redudancy between type params and impl. params, we could allow "borrowing" them from the type annotation:
sum : | a: Num, b: Num | -> Num
sum = || a + b
P.S.
This syntax is used on nushell, which utilizes a similar syntax to the new lambda. It allows an argument to be omitted and used directly, like so:
ls | each {|| get name}
# vs
ls | each {|file| file | get name}
Experiment 2:
By itself I don't like the redundant type + impl names but it does allow the implementation of a feature present in Gleam that is sometimes quite nice:
# Named type parameters (public names different from implementation names)
replace : | in: Str, each: Str, with: Str | -> Str
replace = | string, pattern, substitute |
# ...
good_language_name =
"rocq".replace(each: "q", with: "")
divide : | dividend: Num, divisor: Num | -> Num
divide = | x, y | x / y
Georges Boris said:
I'm +1 that adding some param names is in the worst, neutral.
it's definitely not neutral; it would mean there are two ways to do the same thing, and they could disagree in ways that cause new compiler errors :big_smile:
I think our LSP should do a nice job of formatting the signature to match up the arg names with the same element in the signature. Think:
replace : Str, Str, Str -> Str
replace = |string, pattern, substitute|
Or something of the sort
I'd also only be in favor if we long-term ended up with a single annotation style, inline or out-of-line
Not suggesting this should be the normal formatted presentation
Only for "Hover" operation in LSP
Doesn't seem like anyone likes that idea :rofl:
How about
replace :
Str, # string
Str, # pattern
Str # substitute
-> Str
Or with Richard's example
createLogger :
Level, # min_level
Level, Str => {} # print_fn
-> Logger
I think people miss that if you need arg names, you can wrap in a record
Otherwise, types can remain simple without names
While naming can add clarity in some cases, it also bloats the signature and removes a level of thinking. In functional languages lambdas are common. You don't level the parameters of a nested lambda
Being able to think in lambdas and how they map together is important. Parameters are noise to that form of thinking
Personally, I feel like records are enough for this. If a function is complete enough, that names are wanted in the type, a record can be used. If it is not so complex, regular args can be used. The entire different in syntax is an extra set of {}
I agree that the solution for most of our woes in Rust functions with too many args is to put everything in a record to force readability at the callsite
I also think this pairs well with |...| -> ... syntax for lambda types:
create_logger : |{ min_level: Level, print_fn: |Level, Str| => {}}| -> Logger
create_logger = |{ min_level, print_fn }|
...
Sam Mohr said:
I agree that the solution for most of our woes in Rust functions with too many args is to put everything in a record to force readability at the callsite
This doesn't make sense to me. Rust has inline type annotations and parameters names. If this issue still arises in rust, then inline types don't solve the problem.
Rust doesn't have named fields, which I think is a problem at the call site with complex call sites. What does true do if it's the 6th arg? That is separately solved by named args, or records, which function like named args
Oh, that is a totally different question. I misunderstood. All the rest of this discussion has been about the definition site.
Yep, I should have said "as an aside..."
Most large rust/c++ code bases I have work in just do /*arg_name=*/true to work around that for single problematic args. Roc uses tags to mostly work around that.
tags can work, and inlay type hints also work for Rust/C++
And then what happens next is "all your args in a record" becomes a pattern, and someone asks for sugar for that(with PNC), and then....we have named args
I'd be totally okay with that
Though if we can get away with fewer features and not needing this, I'd love that
That was kind of my point - if we can use tooling to obviate the need for wrapping args needlessly in a record and then going down that language evolution path, we can avoid this.
Anthony Bullard said:
That was kind of my point - if we can use tooling to obviate the need for wrapping args needlessly in a record and then going down that language evolution path, we can avoid this.
I don't understand. Can you... reword? sorry
Sorry Sam, what I meant is if the tooling makes having 3+ args ergonomic enough - then there won't develop a convention of wrapping args in records. And then there won't be asks for the named arg sugar.
Ah, sure
Is there a performance implication to wrapping args in records?
I'd say no ish
If the record is owned in that it's only used once, then it's basically a list of values
If it's used multiple times and not just destructed, then it gets recounted, meaning it's heap allocated
Which is a worthwhile tradeoff for readability in a high-level language like Roc
actually records never get refcounted
Oh?
Good to know. I'll have to properly read the refcount code sometime
yeah the only things that get refcounted are List, Str, Set, Dict, Box, and recursive tag unions
everything else is stack-allocated
also - having a function take a record doesn't just affect the type, it also means you call it with "named arguments"
so having editor tooling show names associated with types doesn't necessarily make it easier to spot when a function that takes 3 strings is taking them in the wrong order
(whereas if you make it take a record, then they're labeled at the call sites)
you can have editor tooling always label all args, but that can get noisy
also, although it's not something we generally do in Roc, it is technically possible to create functions using pointfree function composition, at which point the editor would show actively unhelpful or confusing names for the arguments (probably a and b or arg1 and arg2)
Eli Dowling said:
Is there a performance implication to wrapping args in records?
Technically yes, in practice, probably not
To be specific, the highest cost would come from exceptionally small recursive functions that wrap args in a record only to immediately unwrap them in the function body.
Also, probably would have to be an exceptionally small non-tail recursive function. If it is tail recursive, the transformation to a loop should enable it to optimize away the record I think.
But I would have to double check in specific cases to see if it has any meaningful affect on performance.
if we really wanted to, we could theoretically do a special-case optimization where if you destructure the record right away in the function body, we compile it to exactly the same representation as if there were no record, but it probably wouldn't even be a noticeable performance difference if we did that :stuck_out_tongue:
Georges Boris thought element written slightly differently:
# Impl. only
sum | a, b | = a + b
# Type only
sum | a: Num, b: Num | -> Num
# Type + Impl
sum | a: Num, b: Num | -> Num
= a + b
I added a line break in the last "Type + Impl" case just to show the relation to #ideas > Redundant name in type annotations . If you have the names in the function type there's no need to repeat anything for the implementation.
I think the third example gives a false sense of readability due to small function name + simple types. I tried to test it on a slightly more complicated function + longer name and put it side by side with the "current" syntax:
lets_pretend_walk_has_a_long_name : |
list : List elem,
state : state,
acc : (state, elem -> state)
| -> state =
when list is
[] -> state
[ h, ..tail ] -> walk tail (acc state h) acc
vs
lets_pretend_walk_has_a_long_name :
List elem,
state,
(state, elem -> state)
-> state
lets_pretend_walk_has_a_long_name =
| list, state, acc |
when list is
[] -> state
[ h, ..tail ] -> walk tail (acc state h) acc
Tbh first one looks great. I'm probably too used to inline types, but I really like the fact that if I'd go to 'acc' definition I have it's type right next to it.
If I get this right the argument for out of line types is that it helps to think about function in term of types right?
Can't both be achieved via some 'smart' formatting?
lets_pretend_walk_has_a_long_name : |
list: List elem,
state: state,
acc: (state, elem -> state)
| -> state =
when list is
[] -> state
[ h, ..tail ] -> walk tail (acc state h) acc
One columns for arguments, one column for their types (golang does similar formatting for struct tags).
Personally I think that inline types with that kind of formatting are less noisy than out of line types (fn name specified just once, you can look at either variable names, types or both easily - for var names read first column, for types second column, for both go line by line). Also - bonus points for extra dopamine rush you get when formatter does it's job and your ugly spaghetti mess turns into two perfectly formatted columns.
With out of line types I think you could quickly find yourself jumping between types and variables (especially without LSP support f.e. during PR code reviews).
A problem with using records for named arguments is that you have to duplicate all the argument names.
I also really like the first version of lets_pretend_walk_has_a_long_name where the names and arguments are right next to each other.
Last updated: Jun 16 2026 at 16:19 UTC