I had an idea for a revision to the app module header syntax (and basic-cli entrypoint API) that conveys the same information as today, but in a way that makes Hello World more concise and approachable:
Screenshot 2025-07-31 at 3.37.56 PM.png
so it's just 2 lines of code now
but the horizontal scroll bar is intentionally hiding stuff. after explaining what this part that's visible means, the tutorial could then tell you to add a comma before the } at the end of the first line, which would make roc format change it to:
Screenshot 2025-07-31 at 3.39.47 PM.png
so the main changes to the app header are:
app { ... } and then platform [main!] instead of app [main!] { ... } and then later platform on its ownplatform is intentionally after the URL rather than in front of it, so that it can either be hidden in a tutorial or go on the next line (which I think would the normal way to do it in a normal project)https:// prefix; the assumption would be https:// unless you specifically start your platform location with a . (e.g. "./foo.roc" or "../foo.roc") which indicates opting into a local file pathroc-lang.github.io convention - this is more concise, and also (as it turns out) very easy to set up in a GitHub Action to be a redirect to the longer release URL, so we can presumably bundle that up in a recommended Action for people.these have the goal of making the relevant parts of the platform URL (domain, package name, version number) visible instead of running off the end of the screen. The hash and file extension still run off the end, but those are generally not interesting anyway.
finally, there's also this:
main! = |{ stdout }| stdout.line!("Hello, World!")
the idea here is that, similar to #ideas > fs API using static dispatch, what if the platform just provides all these I/O values like fs, stdout, etc. in main?
edit: we'd also pass in args and env to that record, so in a super featureful scenario you could do like:
main! = |{ args, env, stdout, stderr, stdin, fs, http }|
or if you didn't want to destructure, just:
main! = |cli|
...and then do like cli.args, cli.env, etc.
this makes all the I/O testable, and also makes it obvious which functions are doing I/O - basically all the benefits of #ideas > fs API using static dispatch
at first I thought that would be annoying in scripts, because you'd have to pass fs, stdout, etc. into your top-level functions...but then I realized - they're scripts, and it's all gonna be in one file anyway, so just put those functions inside main! and close over them, easy peasy
and this is actually more concise overall for scripts, because instead of having to add an extra line of import cli.Stderr to start using stderr, you can just add stderr to the existing destructure to bring it into scope
one thing I didn't mention here is that I think there's a cool opportunity to use a feature we've always had but haven't made much use of, namely the fact that you can put a # at the end of the URL to specify which .roc file you want to use as the platform module
so for example:
...tar.bz#safe.roc
this could be how you get "safe script" mode - it's not a separate platform, it's literally basic-cli - just a different entrypoint
so you just add #safe.roc onto the end of that URL and now you're in safe-script mode for whatever basic-cli script you downloaded off the internet
and this way you don't need two sets of docs, or wondering whether they are actually API-compatible, etc...
anyway, curious what anyone's thoughts are on this!
Richard Feldman said:
the idea here is that, similar to #ideas > fs API using static dispatch, what if the platform just provides all these I/O values like
fs,stdout, etc. inmain?
Does this mean if there were other files, the other files would no longer be able to import these sorts of things and instead receive a record as a param in any of their functions that require I/O / any platform functionality?
right
So do you still import things with pf.Stdout?
no need in this design
instead you pass it around
same design Zig is moving toward
I'm probably going to need a more complete app and platform module to really grok this sorry
sure, got a basic-cli app I could port?
Also re the changes to the header... I think it would be nice to use "normal" syntax as much as possible. So like if we could make it a record that would be easiest for people to understand.
The simplest test platform I typically use is my "template" one https://github.com/lukewilliamboswell/roc-platform-template-zig/tree/main/platform
https://github.com/lukewilliamboswell/roc-platform-template-zig/blob/main/examples/hello.roc
We only need two files in this new setup I think, app.roc and platform.roc
oh that one's trivial haha
app [main!] { pf: platform "../platform/main.roc" }
import pf.Stdout
main! : {} => Result {} _
main! = |{}|
Stdout.line!("Roc loves Zig")
Ok({})
becomes:
app { cli: "../platform/main.roc" platform [main!] }
main! : cli.Fx => Result {} _
main! = |{ stdout }|
stdout.line!("Roc loves Zig")
Ok({})
(I don't know if Fx is the right type for that thing that gets passed in, but that can be figured out separately)
Do you have thoughts on the platform header too?
not really
at least not in this thread haha
mainly it's just been on my mind since writing the original tutorial that we kind of hit beginners with a lot up front compared to most other languages
and I've been trying to think how to make it more approachable
Would this be valid?
app {
pf: "../platform/main.roc" platform [main!],
json: "https://github.com/lukewilliamboswell/roc-json/releases/download/0.13.0/RqendgZw5e1RsQa3kFhgtnMP8efWoqGRsAvubx4-zus.tar.br",
}
main! : pf.Io => Result {} _
main! = |{ stdout }| {
jsonStr = Str.toUtf8 "{\"name\":\"Röc Lang\"}"
decoded : Result({ name : Str }, _)
decoded = jsonStr.fromBytes(Json.utf8)
stdout.line!("${decoded?.name} loves Zig")
Ok({})
}
The advantage of app [main!] was that is similar to modules where it exports or provides the main!
the similarity is kind of an upside and a downside though
the downside is that it looks like it's exposing it for other modules to import, but it's not
app { makes it more obvious that you can't import anything from this
Maybe this is silly... but I feel like a record could be more expressive
app { pf: "../platform/main.roc" platform [main!] }
app { pf: { platform: "../platform/main.roc", requires: [main!]} }
I'd love the app & modules & platform & package headers to feel like LEGO blocks that plug together and they all look like they have shapes that "fit"
one of my original goals here is to see if we can have:
,)there are lots of possible designs out there that have various attributes, but to be honest I've never really felt like "wow this app header design is just so much better than the others!"
so if they're all pretty close, why not pick a design from among them that has benefits to beginners?
the # thing has always been there, we just haven't really used it haha
it's really simple: the url links to a tarball of various files, and one of them is a .roc file that's a platform module and that's your platform module
by default we choose main.roc but you can do #whatever.roc at the end of the url to pick a different .roc file in the tarball to use as your platform module
Ah, scheme is the word I was looking for - ftp://, file:// ??
that's it, and that's always been supported (as part of my "no magical entrypoint names; you can always choose whatever name you want for your .roc files)
the realization I had was that this could be used to do variations - like #safe.roc to opt into sandboxed CLI where it prompts the end user before doing I/O
and also we could do like #batch.roc to do something that never deallocates memory and just uses a giant bump allocator for the duration of the entire process, under the assumption that it's not going to be long-running
etc.
So it defaults to say platform.roc (kind of like index.html for a web server) but if you add a hash it uses a different platform module within the platform.
I asume all these platforms would use the same entrypoints and share a host? or maybe we do the thing where the platform module tells roc where the binaries are located... so a platform could ship with multiple host.a included??
Luke Boswell said:
So it defaults to say
platform.roc(kind of like index.html for a web server) but if you add a hash it uses a different platform module within the platform.
well the default has always been main.roc and I don't think we should change that, because it's how platform authors can get the experience of just running roc check with no arguments since that also defaults to main.roc :smile:
Luke Boswell said:
I asume all these platforms would use the same entrypoints and share a host? or maybe we do the thing where the platform module tells roc where the binaries are located... so a platform could ship with multiple
host.aincluded??
it's up to the platform module - the tarball can contain whatever files the platform author decides to put in there, and then the platform module determines which of those files it wants to use
one way to think of it: a tarball just unpacks into a folder, and then whatever files in there work as normal
and roc asks the application what .roc file to use for the application's platform module and then just looks at that file for what to do next
so if there are other files hanging around in that same directory, it doesn't really know or care
similarly if some of those other files happen to be .roc files that are platform module, and some of those happen to reuse the same host files, again roc doesn't know or care
Yes, but currently the platform host is hardcoded and assumed to be sitting next to the main.roc. I think our plan is to have that anywhere inside the tarball folder... and the platform header has some way to say, go get my linux-x64 archive from here
I think we're on the same page
In summary I think app { pf: "../platform/main.roc" platform [main!] } is worth trying :thumbs_up:
I'm not sure about passing platform functions to main. It makes 'normal' imports and platform imports feel different. But that might be a good thing as well. I'd need to see and use the proposed way to get a proper opinion.
The new header looks a bit cleaner to me though, so I like that
I like the explicitness of having main accept platform-specific args! And if those args are the primary ways a user create effectful functions, I think this would encourage more pure functions in user code (since passing those args everywhere adds friction)
The downside I see is mainly verboseness. I could see it being a little painful in larger codebases
I think now is a good time to mention that OCaml's module system basically solves the verbosity problem of passing around effectful functions and configuration data between modules. Basically they have two kinds of modules: static modules and "functor" (terrible name IMO) modules. The functor modules are basically just functions that can accept one or more modules and return a new module.
Here is an example. I'll use ReasonML (an alternate syntax for OCaml) just because it probably feels more familiar to most people:
module WithNetwork = (NetworkIO: {
let send: string => unit;
let listen: (string => unit) => unit;
}) => {
let sendStuff = NetworkIO.send;
let listenToStuff = NetworkIO.listen;
module Internal = NetworkIO;
};
Here is the ReasonML playground link to check it out:
https://reasonml.github.io/en/try.html?rrjsx=true&reason=LYewJgrgNgpgBAdQJYBcAWA5GKDuIBOA1nALwBQclVcAFBddVrgYQJIDyAXHAN70PVYKOAGcYAOzDcRKfEnEBzUgD44EcagDc-AZSFwoSGRO40ZcxSrUaUASivqtOhgF8ANM8r2SqvnuyiEmAAyigQAGbhpHBMeEQcAHRiktr+wobG4gAqIKERUSQx2HFs7AkZKBKpcKCQsHCs4pX44gCGUNGxLBzaLtpAA
Not sure if this is the direction you guys want to go with this, but I've always found this to be incredibly useful, as it's basically like OOP-style dependency injection but without all the weird OOP baggage.
This allows you to have module-scoped types/values so you can do the dependency injection at a macro level and avoid having to pass in those dependencies to each individual function.
We can already kinda do this in Roc with just plain functions that take/return structs of functions, which we can treat as "modules":
create_api :
{
send : Str -> {},
listen : (Str -> {}) -> {},
}
-> {
addPrefixAndSend : Str, Str -> {},
init : {} -> {},
}
create_api = |{ send, listen }|
addPrefixAndSend = |prefix, msg|
send(Str.concat(prefix, msg))
init = |_|
listen(|value| addPrefixAndSend("🐈 ", value))
{ addPrefixAndSend, init }
The point is, we can use the above style to kinda get around some of the function-passing for platform APIs.
And I would strongly encourage new users to do it this way, especially as they build their own domain-specific IO utilities. That way they can still get all the testability benefits without exposing half of their codebase to that function-passing boilerplate.
This is the de facto standard way of designing APIs in OCaml/ReasonML, and for good reason.
Technically OCaml's module system is more powerful at the type level (e.g. you can simulate higher-kinded-types with functor modules), but that isn't really necessary here.
It would be pretty cool if we could actually do dependency injection at the module level (without OCaml's fancy type-level stuff), just to make this pattern feel more "official" and avoid having to use both modules and records as API namespaces. But it definitely isn't necessary.
This is actually currently implemented in the old compiler, we call it module params: https://github.com/roc-lang/roc/blob/main/crates/cli/tests/test-projects/module_params/Api.roc
I'm on board with what's suggested in the proposal, although I'd also like to have a proper record instead of: { pf: "../platform/main.roc" platform [main!] }. In the tutorial and when teaching things to beginners we can format things how we want, so have the record on a single line. I feel like it's ok for the formatter to always turn that into multiple lines.
@Anton did you have any ideas for a proper record?
Did you like my suggestion here?
Yeah, perhaps it can be even further simplified:
app { pf: "../platform/main.roc", requires: [ main! ] }
How do we know what are the Packages other than the platform package?
Based on the requires:? We'd need to make it a list as well:
app [{ pf: "../platform/main.roc", requires: [ main! ] } ]
I'm going to take a wager that 1) the changes to the platform/app header make it seem simpler and don't really seem to have any obvious downsides 2) beginners will find it more confusing to import some things (like in every other language) but take other things as parameters to main!. It introduces a split every time they think "I want to use this thing, oh I'll import it - wait, should I do the param-style "import" or the normal way?"
I do also share some of the concerns around introducing extra verbosity for effectful code. I've dealt with some Haskell that was written in a record-of-functions style and it gets a little tedious to have an extra parameter for every effectful function (probably a main driver for why mtl and effect systems at the type level are way more popular).
Austin Davis said:
The point is, we can use the above style to kinda get around some of the function-passing for platform APIs.
I'm curious to learn more about this - right now I'm a little confused how the example gets around function passing. Once you call create_api and get back a record, don't you still have to pass that record as a parameter to all functions that want to use the API?
Edit: I think I see now - you would essentially write the entire module inside the outer function, treating the outer function like a functor in the OCaml/Reason example. Makes sense.
Hubbard Joppa said:
I do also share some of the concerns around introducing extra verbosity for effectful code. I've dealt with some Haskell that was written in a record-of-functions style and it gets a little tedious to have an extra parameter for every effectful function (probably a main driver for why mtl and effect systems at the type level are way more popular).
yeah that's a downside, but it's by far the simplest way I've found to make I/O logic actually testable without having to run the real I/O, and I really want to encourage that style of testing. I like that this makes it really obvious how to do that.
I've had a good experience using that style at Zed in Rust, we also used it at NoRedInk in Haskell to good effect (I think @Jasper Woudenberg introduced it if I remember right?) and Zig is also about to start doing it in the stdlib.
I've never seen a nicer way to test effects in any programming language, and I think it's worth trying out as a default rather than something that people end up having to bolt onto a foundation that has no story for simulating effects
True, if there's no approach presented early on for the community to coalesce around, things will get messier.
Richard Feldman said:
I've never seen a nicer way to test effects in any programming language, and I think it's worth trying out as a default rather than something that people end up having to bolt onto a foundation that has no story for simulating effects
Algebraic effect systems are pretty beautiful in this regard IMHO. The fancy name makes it sound overly complex, but it's just a list of types of effects used by a function, and then callers can specify the implementation. It kind of goes along with Roc's ->/=> distinction, but would specify the name of effects used by the =>. Might be more confusing for beginners, though, I'm not sure.
Austin Davis said:
It would be pretty cool if we could actually do dependency injection at the module level (without OCaml's fancy type-level stuff), just to make this pattern feel more "official" and avoid having to use both modules and records as API namespaces. But it definitely isn't necessary.
Did you have something in mind for the syntax that would make this pattern more "official"? It seems like your code snippet pretty much covers all the bases. I think if Roc goes in the direction Richard's leaning toward w.r.t not importing effects, I'd probably end up structuring all my code like this.
Hubbard Joppa said:
Algebraic effect systems are pretty beautiful in this regard IMHO. The fancy name makes it sound overly complex, but it's just a list of types of effects used by a function, and then callers can specify the implementation. It kind of goes along with Roc's
->/=>distinction, but would specify the name of effects used by the=>. Might be more confusing for beginners, though, I'm not sure.
yeah we've talked about that. We intentionally decided to go wit this "binary algebraic effects" (-> vs =>) instead of a full OCaml/Koka/Unison-style algebraic effects system with handlers and effect polymorphism.
I think the main tradeoffs here are:
List.map takes a function whose effectfulness is polymorphic", so a lot of higher-order function types have to get more complicated and less beginner-friendly, whereas in our current design you can just offer map and map! and you're donealso of note, in many cases you're passing around some state anyway and don't need an extra argument - e.g. you can have my_state.fs.read!(...) and not need to pass fs as a separate argument since you're already passing my_state around
granted, I haven't used algebraic effects in a sizable code base in anger, but it feels like the selling point for this scenario (still change the type but don't change the call site to need an extra arg) is not worth the language complexity
Is the goal to make this work with module params too? I vaguely remember we scrapped that for the new compiler.
the goal is to do this instead of module params
module params, algebraic effects, and static dispatch can all be used to get (imo) similar levels of ergonomics
as usual, I'm happiest if we can just do it with static dispatch instead of adding other language features to get approximately the same ergonomics :smile:
to give an idea of a small but nontrivial example with this style, I asked Claude to port our bash script for building the website to Python, and then ported that to Roc in this style:
(I made up some Roc APIs that don't exist yet, e.g. streaming I/O)
I like the look of that Roc script :grinning_face_with_smiling_eyes:
I agree with our dear BDFN, I think this is a really nice design choice. I would definitely appreciate having module params just to make things feel more consistent when doing that dependency injection pattern, but I do think it adds some complexity (to both the compiler and the language), and it probably feels a bit foreign to a lot of newcomers who haven't used OCaml/ReasonML.
I also just want to say, I really REALLY appreciate all the work and mental energy that was put into this language, and I can't emphasize enough how nice it is to have a simple & expressive funcitonal language that runs wicked fast.
My elevator pitch for Roc right now is that it's basically all the best things about Elm, Rust, and Lua, but without all the bad stuff. I can mostly just write data types and functions and just get shit done. I don't have to think too hard because it's such a simple language. I have even moved away from Bash/Python for scripting in favor of Roc. It just feels great to work with it. Still needs some sanding around the edges, but dang, I'm so impressed!
thanks! It's been a lot of work, but I'm really excited about where we've gotten to, and I'm stoked to see what people think of it for Advent of Code assuming we achieve our goal of having the new compiler usable for it :grinning_face_with_smiling_eyes:
fwiw I use the style of passing in effectful functions as an argument in my Roc apps and it’s been nice. I’ve ended up putting my most commonly used effects in a single record that I pass to most effectful functions. Then each function sig uses the record matching syntax to spell out which effects it needs. Turn out really neat, ergonomic, and clear :+1:
@Niclas Ahden do you have any examples or snippets you could share?
Probably! I’m on vacation right now but I’ll try to remember to return to this when I’m back :)
One thing that I love about this is that if Std{In|Out|Err} becomes just a interface then you would be able to create applications that are capabilities secure to your Roc dependencies. So even without safe script you could limit what deps could do with IO.
This was one of the core features of Chakra, except it did it by having the IO functions in the stdlib, but they all required capabilities and those capabilities could not be constructed in Chakra code, they were given to the main actor in it's init function. There were then functions that could create more restricted versions of those capabilities to pass on to other functions. They would fail if you tried to broaden the capability(say authorize the filesystem capability to include a sibling or parent directory)
So, io.stdout.write had the signature io.stdout.write(StdoutCap, String) Cmd (Chakra had managed effects like Elm). If you weren't passed that capability, you couldn't write!
Last updated: Jun 16 2026 at 16:19 UTC