Stream: beginners

Topic: roc style guide


view this post on Zulip Steve Howell (Oct 28 2026 at 13:49):

I had Claude write a style guide for coding Roc on my projects. I would love for a human to give it an adversarial review (even subjective things).

https://github.com/showell/roc-apps/blob/master/docs/roc-style.md

I tried to make the style guide fairly general, but it includes a few things specific to my workflow. Folks are welcome to fork this, and most of the Steve-specific stuff should be easy for an agent to remove.

view this post on Zulip notramo (Oct 28 2026 at 14:37):

Some tips for state machines:

view this post on Zulip Steve Howell (Oct 28 2026 at 15:07):

@notramo Thanks!

Style guide:

New Shale check: https://github.com/showell/shale/commit/0b59e5102dbd14bf06f978f72325619cd0b3d9a1

Code changes: https://github.com/showell/roc-webbox/commit/692aa768ee83fc29809b5f8a222216330af9053a

view this post on Zulip notramo (Oct 28 2026 at 22:43):

I noticed that certain cheaper LLMs tend to underuse the pipe operator and methods, and nest the function calls instead. Add this:

view this post on Zulip notramo (Oct 28 2026 at 22:44):

Some more:

view this post on Zulip notramo (Oct 28 2026 at 22:44):

view this post on Zulip notramo (Oct 28 2026 at 22:47):

view this post on Zulip notramo (Oct 28 2026 at 22:49):

view this post on Zulip notramo (Oct 28 2026 at 22:52):

view this post on Zulip notramo (Oct 28 2026 at 22:56):

view this post on Zulip notramo (Oct 28 2026 at 22:59):

view this post on Zulip notramo (Oct 28 2026 at 23:01):

Extend this rule:

I would add this sentence:

This is known as the "newtype pattern" in Rust, and Roc has excellent support for it.

view this post on Zulip notramo (Oct 28 2026 at 23:07):

view this post on Zulip Luke Boswell (Oct 28 2026 at 23:11):

I have a gist I've been periodically updating, I fed claude the above notes and pointed it at a some examples I had and updated it https://gist.github.com/lukewilliamboswell/241a4e8adb7c89c7e0e02f1d303a8fa1

view this post on Zulip notramo (Oct 28 2026 at 23:19):

Inline a helper that is used once and is under 3–4 lines, or that mostly depends on behaviour defined elsewhere.

I think most LLMs will get confused by this shorter definition. It's not always valid. It's a rule of thumb, not a hard rule. Just to make the bias a bit weaker, not to completely disable. It's mostly a workaround for weaker LLMs. An LLM that would not do it by default just gets confused by this, so I would recommend adding the longer explanation.

Use := where values of the same shape mean different things, so passing a name where an id belongs is a type error.

If you want it concise, simply add this to the end: (a.k.a. "newtype" pattern in Rust).
The "newtype" word probably would activate some Rust-related weights, that would benefit from the usage examples in the vast Rust training data, to help in deciding when to use it.

view this post on Zulip notramo (Oct 28 2026 at 23:22):

Some more rules:

view this post on Zulip Steve Howell (Oct 28 2026 at 23:27):

Thanks guys! I'll have Claude process these tomorrow. For things that are additive, I will probably only add them to the style guide if Claude finds counterexamples in my Roc code--I have a large enough sample size for this.

Are there any points in my style guide that seem wrong? (as opposed to incomplete) Again, subjective is fine.

view this post on Zulip notramo (Oct 28 2026 at 23:29):

For things that are additive, I will probably only add them to the style guide if Claude finds counterexamples in my Roc code--I have a large enough sample size for this.

I wrote these to be as general and model-agnostic as possible. Some bad patterns may not appear in Claude-written code, but other LLMs might use them. (Every rule was inspired by real-world bad code that LLMs generated.) Is it meant to be a Claude-only guide?

view this post on Zulip notramo (Oct 28 2026 at 23:30):

Are there any points in my style guide that seem wrong? (as opposed to incomplete) Again, subjective is fine.

I could not find while reading it. But the question is, maybe something is misinterpreted by some LLMs or steer them too much into a certain direction. I'll test it in practice tomorrow.

view this post on Zulip Steve Howell (Oct 29 2026 at 00:10):

notramo said:

I wrote these to be as general and model-agnostic as possible. Some bad patterns may not appear in Claude-written code, but other LLMs might use them. (Every rule was inspired by real-world bad code that LLMs generated.) Is it meant to be a Claude-only guide?

Mostly Claude. Also, the scope is even more narrow than that--I am only adding things where I have seen clear violations in my own Roc codebase, because I want to keep it uncluttered. That's why I encourage folks to keep their own forks, since they may use agents less, use different agents, or have different forces in their codebase.

view this post on Zulip Steve Howell (Oct 29 2026 at 00:37):

Two things I like:

Code (roc-webbox):

Shale (enforcement):

The Claude Console's copy/paste function is broken, hence some of the truncations.

view this post on Zulip pixelsprout (Oct 30 2026 at 02:22):

Does anyone have a preference to not use method syntax?

For example, comparing:

match messages.last() {
    ...
}

to

match List.last(messages) {
   ...
}

I have found agents to prefer the latter one. One benefit of the second one is that it's clear what type you are dealing with at a glance. :thinking:

view this post on Zulip Luke Boswell (Oct 30 2026 at 02:28):

I prefer the method style, mostly because it's shorter

view this post on Zulip pixelsprout (Oct 30 2026 at 02:31):

I suppose it reads nicer too!

view this post on Zulip Luke Boswell (Oct 30 2026 at 02:33):

Reading it I think is clearer to, like putting the subject first in a sentence. To me it feels like "match on the last message" as opposed to "match on the last value in the list called messages". I don't know how well that holds up grammatically though

view this post on Zulip Aurélien Geron (Oct 30 2026 at 02:42):

Static dispatch (and pipe chains) become even nicer when nested calls are involved, such as:

"Hello world!".split_on(" ") |> Str.join_with(", ")

With regular calls, it would look like this:

Str.join_with(Str.split_on("Hello world!", " "), ", ")

view this post on Zulip notramo (Oct 30 2026 at 09:55):

I have found agents to prefer the latter one.

I guess it's due to the majority of FP training data being from FP languages that don't have method syntax.

view this post on Zulip Steve Howell (Oct 30 2026 at 11:54):

Isn't the method syntax fairly new even in Roc?

view this post on Zulip removewingman (Oct 30 2026 at 12:37):

Most functions just take arguments and the user has to know or look up in the documentation, what each argument means, e.g.

Str.replace_each : Str, Str, Str -> Str # first is input Str, second argument is delimiter, third replacement
expect "a,b,c".replace_each(",", " and ") == "a and b and c"

I personally prefer this kind of style, and it is fairly easy in roc:

replace_each : Str, { delimiter: Str, replacement: Str } -> Str
expect "a,b,c".replace_each({ delimiter: ",", replacement: " and " }) == "a and b and c"

it seems like the first one is basically the standard, what are your thoughts on the second API style?

view this post on Zulip notramo (Oct 30 2026 at 12:38):

Doesn't it add some overhead?

view this post on Zulip Steve Howell (Oct 30 2026 at 12:43):

My style guide addresses the pitfall that Str, Str, Str -> Str doesn't give you enough semantic info with the following rule (and some other rules are also kinda relevant):

Neighbouring arguments of one type get a record, even when there are only two of them: they can be swapped and still type-check, and a record names the fields, so order can't go wrong: appended!(conv, who, draft), not four Strs in a row (records).

view this post on Zulip notramo (Oct 30 2026 at 12:45):

I prefer named arguments, and would like to see them in Roc, but usually it's not that big issue like in other languages.

view this post on Zulip Steve Howell (Oct 30 2026 at 12:46):

I think removewingman is speaking to a slightly broader issue than I mentioned above, to be clear. My answer: I think there are cases where the more explicit style is worth it.

view this post on Zulip notramo (Oct 30 2026 at 12:47):

For example, when I write Zig or TypeScript functions, those have more arguments on average than Roc functions. I don't know why, maybe different code organization.

view this post on Zulip Steve Howell (Oct 30 2026 at 12:50):

My biggest pet peeve is when my fellow humans write code like foo(true, false, true). I have seen this countless times. You have no idea what the booleans mean locally. Especially in JS, I got in the habit of just passing structs to almost every non-trivial function.

view this post on Zulip notramo (Oct 30 2026 at 13:06):

For bools, it's often better to name the choices, e.g. [Offline, Online] instead of [True, False]. So it's self-documenting, even without named params.

view this post on Zulip notramo (Oct 30 2026 at 13:07):

It's also possible in Zig with anonymous enums, and TypeScript with literal strings as types, e.g. 'online' | 'offline'.

view this post on Zulip notramo (Oct 30 2026 at 13:08):

Although the strings might cause some performance overhead, but if performance is important for a task, TypeScript is a quite bad choice anyways.

view this post on Zulip Steve Howell (Oct 30 2026 at 13:09):

notramo said:

For bools, it's often better to name the choices, e.g. [Offline, Online] instead of [True, False].

already in my style guide as of yesterday

view this post on Zulip Steve Howell (Oct 30 2026 at 13:11):

notramo said:

Although the strings might cause some performance overhead, but if performance is important for a task, TypeScript is a quite bad choice anyways.

You can write very performant TypeScript. It's been a while--doesn't TS have enums that are strings in the source code but still ints in the generated JS?

view this post on Zulip notramo (Oct 30 2026 at 13:12):

It has, but it's a bad feature.

view this post on Zulip notramo (Oct 30 2026 at 13:15):

An LLM can explain why.

view this post on Zulip Steve Howell (Oct 30 2026 at 13:16):

pixelsprout said:

Does anyone have a preference to not use method syntax?

The consensus is that the method syntax is pretty popular. I only need to rule for myself, not the group, but I prefer method syntax as well.

This is 485 lines of shale, because it's a non-trivial refactoring where Claude uses the Roc compiler to verify rewrites are safe.

view this post on Zulip Steve Howell (Oct 30 2026 at 13:28):

view this post on Zulip Steve Howell (Oct 30 2026 at 14:35):

https://github.com/showell/roc-apps/blob/master/CLAUDE.md is also relevant:

Contents:

view this post on Zulip Steve Howell (Oct 30 2026 at 14:44):

(My CLAUDE.md is under the same universal license as Roc, so folks can steal/crib/fork as they want.)

view this post on Zulip Dan G Knutson (Oct 30 2026 at 22:06):

notramo said:

I have found agents to prefer the latter one.

I guess it's due to the majority of FP training data being from FP languages that don't have method syntax.

I wonder if part of this is also that Builtin.roc uses Module.function() style

view this post on Zulip notramo (Oct 30 2026 at 22:07):

Maybe to a less extent. But I think the knowledge cutoffs are more likely causing it, since method syntax is relatively recent.

view this post on Zulip notramo (Oct 31 2026 at 23:00):

@Steve Howell, why is the linting in Shale? Is Roc unsuitable for it?

view this post on Zulip Steve Howell (Oct 31 2026 at 23:56):

notramo said:

Steve Howell, why is the linting in Shale? Is Roc unsuitable for it?

Saying that Roc is unsuitable would be too strong a statement. It's just not Roc's sweet spot. I'm not a one-language-must-solve-all-problems kinda guy. None of my apps are pure Roc--I usually have a thin JS layer for canvas apps, and I usually have zig for stuff close to the metal.

view this post on Zulip notramo (Oct 31 2026 at 23:56):

What are its disadvantages?

view this post on Zulip Steve Howell (Oct 31 2026 at 23:59):

Shale isn't really "replacing" Roc for agent tasks, because my agents reach for bash and Python for the kind of tasks that I'm migrating to Roc. Shale doesn't have Roc's typing system, because you don't need it for small agent tasks. Also, the entire point of Shale scripts is their effects, so effects are "native" to Shale.

view this post on Zulip Steve Howell (Nov 01 2026 at 00:00):

As I said in #off topic > shale programming language the languages are complementary.

view this post on Zulip notramo (Nov 01 2026 at 00:00):

Wouldn't it be easier to write linter functions in Roc?

view this post on Zulip notramo (Nov 01 2026 at 00:01):

I created my own style guide, and it would be great to have a customizable linter, where anyone could write rules in Roc, or import rules written by others.

view this post on Zulip Steve Howell (Nov 01 2026 at 00:02):

notramo said:

Wouldn't it be easier to write linter functions in Roc?

No. It's the same reason you don't write a Roc compiler in Roc. The linter functions do walk the Roc AST.

To be clear, it's not just the linting that happens in Shale; it's acting upon the lints that happens in Shale. If I were only looking for anomalies, the case for Shale would be less compelling.

view this post on Zulip Steve Howell (Nov 01 2026 at 00:05):

It's worth noting that Shale is interpreted, too. That's why it's more in the class of bash and Python. Even though I have many "permanent" shale scripts, my agents also do tons of ad hoc Shale.

view this post on Zulip notramo (Nov 01 2026 at 00:06):

But the changes could be done in Roc pure functions and applied afterwards.

view this post on Zulip notramo (Nov 01 2026 at 00:06):

It's the same reason you don't write a Roc compiler in Roc

The FAQ lists performance as the only reason.

view this post on Zulip Steve Howell (Nov 01 2026 at 00:08):

notramo said:

But the changes could be done in Roc pure functions and applied afterwards.

Show me the code. :)

view this post on Zulip notramo (Nov 01 2026 at 00:08):

I'll explore a vibe-coded linter.

view this post on Zulip Steve Howell (Nov 01 2026 at 00:15):

notramo said:

I'll explore a vibe-coded linter.

Cool! I asked Claude for a couple examples that you can point your agent at:

Shale is public on GitHub. From easiest to hardest:

  1. nest_depth.shale (160 lines): flags a call nested three deep, f(g(h(x))). It's one rule and a simple walk over Roc's parse tree.
    https://github.com/showell/shale/blob/master/cookbook/roc/nest_depth.shale

  2. errors.shale (312 lines): two error-style rules from the Roc style guide, an error alias where tags should be written out in place, and an optional value written as a tag. It has to follow type aliases and module types, so it reads the tree with more context.
    https://github.com/showell/shale/blob/master/cookbook/roc/errors.shale

  3. boundaries.shale (441 lines): finds every top-level and module-member function and every reference to it, and flags a parameter that is never used. This means resolving names across a file, so it's the closest of the three to real analysis.
    https://github.com/showell/shale/blob/master/cookbook/roc/boundaries.shale

Useful context for him:

If he wants a fourth that's mostly heuristics rather than structure, there's name_hunt.shale (681 lines). It applies five naming heuristics and reports each finding as a question.

view this post on Zulip notramo (Nov 01 2026 at 00:17):

I'm not sure about the architecture. Probably a Zig-based platform, that's using the compiler sources directly.

view this post on Zulip Steve Howell (Nov 01 2026 at 00:19):

Shale is zig under the hood. :smile: So it should be easy to steal shale code for your Roc platform. Shale will be under UPL in a sec.

view this post on Zulip Steve Howell (Nov 01 2026 at 00:20):

Ok, Shale is now officially under the same license as Roc (s/Richard/Steve):

https://github.com/showell/shale/blob/master/LICENSE

view this post on Zulip Steve Howell (Nov 01 2026 at 00:22):

From my Claude:

Yes. Shale uses the Roc compiler's own parser, built straight from its sources, but not inside Shale itself. It goes through a small separate program:

Why it isn't linked into Shale:

  1. Zig versions: Roc's build needs exactly Zig 0.16 (roc-ast's build refuses anything else), while Shale is built with our patched Zig 0.17. They can't be one build today.
  2. Dependencies: pulling the whole Roc compiler into Shale's build would make every Shale build depend on Roc. Our rule is to avoid dependencies, and a separate program keeps Shale independent.
  3. Pinning: the parser has to match the Roc a project is built with. A separate program can be pinned and rebuilt on its own schedule.

What it costs us:

For notramo, this is the real case for native-Roc linters: if Roc ever exposes its parser to Roc code, a linter gets the tree as data, with no text to re-parse and no version to drift.

view this post on Zulip Steve Howell (Nov 01 2026 at 00:24):

Executive summary: I think notramo should explore using compiler sources directly. The reasons I don't are related to some Steve-specific factors related to my ecosystem of Roc apps.

view this post on Zulip notramo (Nov 01 2026 at 00:24):

Isn't Roc already upgraded to Zig 0.17?

view this post on Zulip Luke Boswell (Nov 01 2026 at 00:25):

I have a linter written in Roc. It's called roc-policy any is configurable, with some additional test inventory and execution features. It checks conventions and some random things the compiler doesn’t cover.

The confgiguration lives in Blueprint.roc. You write profiles, select files with globs, set warning/error levels, and declare exceptions etc.

roc-policy check --root .
roc-policy check --root . --format json
roc-policy explain-config --root . --path package/Thing.roc

Download it from a release https://github.com/lukewilliamboswell/roc-automation/releases/tag/tools-0.8.0

view this post on Zulip Steve Howell (Nov 01 2026 at 00:25):

notramo said:

Isn't Roc already upgraded to Zig 0.17?

I created Shale a couple weeks ago, when that transition was still very much in flux. It's quite possible that some of the things that Claude did that recently are already moot. Life on the bleeding edge. :)

view this post on Zulip notramo (Nov 01 2026 at 00:28):

@Luke Boswell, can I write custom rules for it?

For example:

view this post on Zulip Steve Howell (Nov 01 2026 at 00:28):

I should also point out that Shale was originally written for manipulating other languages, notably zig. It's more general-purpose than just fixing Roc, which is a weakness if you are only doing Roc, but it's a strength for me.

view this post on Zulip Luke Boswell (Nov 01 2026 at 00:30):

@notramo we would need to extend the tool to do that I think. But I'm happy to take PR's and improve it and make it more useful for people. So far it's just me hacking away and this kind of detail has been a lower priority.

view this post on Zulip notramo (Nov 01 2026 at 00:31):

So I would have to add my rules to upstream, which everyone uses? Or some plugin system?

view this post on Zulip Luke Boswell (Nov 01 2026 at 00:32):

The capability to handle those rules would need to be added upstream (unless you fork it) and then you can make a policy that uses those rules.

view this post on Zulip notramo (Nov 01 2026 at 00:32):

Does it have access to the type system? Or just syntax tree?

view this post on Zulip Luke Boswell (Nov 01 2026 at 00:33):

Just syntax

view this post on Zulip Luke Boswell (Nov 01 2026 at 00:33):

If someone ported the typechecker to pure Roc that would be epic

view this post on Zulip Luke Boswell (Nov 01 2026 at 00:33):

Before then we would also need semantic analysis though too ... which is a massive job

view this post on Zulip notramo (Nov 01 2026 at 00:34):

If someone ported the typechecker to pure Roc that would be epic

I guess an LLM couldn't do that currently. It's too complex.

view this post on Zulip Luke Boswell (Nov 01 2026 at 00:36):

An LLM could probably do it ... now that we have a working oracle in the Zig compiler. If I was taking a stab at it I would first build a harness and it could iterate with a fuzzer to find inputs that break -- all the way until you have a clean or complete implementation (or at least cant find any inputs where the implementations give different results).

It's definitely not an easy or small project though.

view this post on Zulip Steve Howell (Nov 01 2026 at 00:38):

notramo said:

If someone ported the typechecker to pure Roc that would be epic

I guess an LLM couldn't do that currently. It's too complex.

An LLM absolutely could port the typechecker to Roc. It's just silly to do it when you already have it in zig.

To be clear, I only use the syntax tree in Shale, and this does cause some things to be more clumsy than if I accessed the type system. Instead, Shale attempts certain refactorings and runs them through Roc directly to type-check them.

view this post on Zulip Luke Boswell (Nov 01 2026 at 00:40):

There is a plan that has been discussed for some time but not built yet, for a roc tooling plugin similar to how you can write SomeGlue.roc you could write SomeLSP.roc or SomeLinter.roc and the compiler gives you access to all kinds of internals and hooks you can use to build tooling. The design is pretty fuzzy at the moment, but I understand the long term plan is that things like the LSP can be built purely in Roc and the compiler doesnt need to be coupled to that standard or even understand things like JSON.

view this post on Zulip Luke Boswell (Nov 01 2026 at 00:54):

@notramo I had Astra summarise the old threads into a gist https://gist.github.com/lukewilliamboswell/b477d6f6024fc43b9c3d5784a5da20af

view this post on Zulip Luke Boswell (Nov 01 2026 at 00:54):

This is an overview of the proposed roc tooling design discussion

view this post on Zulip notramo (Nov 01 2026 at 01:00):

Thanks!

view this post on Zulip Steve Howell (Nov 01 2026 at 04:17):

@Luke Boswell There appears to be a tiny bug with roc-parser on the following code:

module [f]

f : (U8 -> Bool) -> U64
f = |_| 0

roc-parser gives the inner TypeFunction 16..27, which is (U8 -> Bool. The compiler gives 17..27, which is U8 -> Bool.

view this post on Zulip Steve Howell (Nov 01 2026 at 04:18):

Claude found that as part of investigating roc-parser for my own use case. Hopefully tomorrow I will start using roc-parser under the hood for my own linting (still with shale and some of my own style nitpicks).

view this post on Zulip Luke Boswell (Nov 01 2026 at 04:22):

Would you mind making a GH issue or a PR please?

view this post on Zulip Steve Howell (Nov 01 2026 at 04:25):

https://github.com/lukewilliamboswell/roc-parser/issues/101

Feel free to DM/mention me here for any follow-up.

view this post on Zulip Luke Boswell (Nov 01 2026 at 04:26):

Fixing it now ... also going to harden my fuzz targets to try and catch this and similar issues

view this post on Zulip Luke Boswell (Nov 01 2026 at 04:31):

I found a whole class of bugs my oracle harness was blind to, thanks to this report

view this post on Zulip Steve Howell (Nov 01 2026 at 04:31):

You're welcome! Thank you as well!

view this post on Zulip Dan G Knutson (Nov 01 2026 at 19:14):

The recent Gergely interview with the Google tooling guy made me curious about declarative code mods for Roc. Something like the roc tooling linter or elm-review, but more focused on applying some kind of structured edit across a codebase. Like "replace all usages of this method with this other one in this particular way".

view this post on Zulip Steve Howell (Nov 01 2026 at 19:39):

@Dan G Knutson That's what shale is doing: structured edits. Shale reads the AST to find the structural changes it needs to make, using the AST to determine the structure and to know where the tokens are in the file. And then it uses primitives like replace() to edit the file.

view this post on Zulip Steve Howell (Nov 01 2026 at 21:53):

Just to be clear, Shale doesn't have any elegant syntax that is specific to refactoring. Every refactoring just has a concrete program that walks the AST and looks for things. For example, let's say I wanted to flag any potential call stack that was more than N deep. In Shale, just as I would in any other mainstream programming language, I walk the AST, only look at function-call nodes, and increment the count as I walk down the tree.


Last updated: Nov 04 2026 at 18:54 UTC