Besides having a friendly community, Roc also prioritizes being a user-friendly language. This impacts the syntax, semantics, and tools Roc ships with.
Syntax and Formatter
Roc's syntax isn't trivial, but there also isn't much of it to learn. It's designed to be uncluttered and unambiguous. A goal is that you can normally look at a piece of code and quickly get an accurate mental model of what it means, without having to think through several layers of indirection. Here are some examples:
user.emailalways accesses the
user. (Roc has no inheritance, subclassing, or proxying.)
Email.isValidalways refers to something named
isValidexported by a module named
x = doSomething y zalways declares a new constant
x(Roc has no mutable variables, reassignment, or shadowing) to be whatever the
doSomethingfunction returns when passed the arguments
z. (Function calls in Roc don't need parentheses or commas.)
"Name: \(Str.trim name)"uses string interpolation syntax: a backslash inside a string literal, followed by an expression in parentheses. This code is the same as combining the string
"Name: "with the string returned by the function call
Because Roc's string interpolation syntax begins with a backslash (just like other backlash-escapes such as
\"), you can always tell which parts of a string involve special handling: the parts that begin with backslashes. Everything else works as normal.
Roc also ships with a source code formatter that helps you maintain a consistent style with little effort. The
roc format command neatly formats your source code according to a common style, and it's designed with the time-saving feature of having no configuration options. This feature saves teams all the time they would otherwise spend debating which stylistic tweaks to settle on!
Roc's compiler is designed to help you out. It does complete type inference across all your code, and the type system is sound. This means you'll never get a runtime type mismatch if everything type-checked (including null exceptions; Roc doesn't have the billion-dollar mistake), and you also don't have to write any type annotations—the compiler can infer all the types in your program.
If there's a problem at compile time, the compiler is designed to report it in a helpful way. Here's an example:
── TYPE MISMATCH ─────── /home/my-roc-project/main.roc ─ Something is off with the then branch of this if: 4│ someInt : I64 5│ someInt = 6│ if someDecimal > 0 then 7│ someDecimal + 1 ^^^^^^^^^^^^^^^ This branch is a fraction of type: Dec But the type annotation on `someInt` says it should be: I64 Tip: You can convert between integers and fractions using functions like `Num.toFrac` and `Num.round`.
If you like, you can run a program that has compile-time errors like this. (If the program reaches the error at runtime, it will crash.)
This lets you do things like trying out code that's only partially finished, or running tests for one part of your code base while other parts have compile errors. (Note that this feature is only partially completed, and often errors out; it has a ways to go before it works for all compile errors!)
When dealing with serialized data, an important question is how and when that data will be decoded from a binary format (such as network packets or bytes on disk) into your program's data structures in memory.
A technique used in some popular languages today is to decode without validation. For example, some languages parse JSON using a function whose return type is unchecked at compile time (commonly called an
any type). This technique has a low up-front cost, because it does not require specifying the expected shape of the JSON data.
Unfortunately, if there's any mismatch between the way that returned value ends up being used and the runtime shape of the JSON, it can result in errors that are time-consuming to debug because they are distant from (and may appear unrelated to) the JSON decoding where the problem originated. Since Roc has a sound type system, it does not have an
any type, and cannot support this technique.
Another technique is to validate the serialized data against a schema specified at compile time, and give an error during decoding if the data doesn't match this schema. Serialization formats like protocol buffers require this approach, but some languages encourage (or require) doing it for all serialized data formats, which prevents decoding errors from propagating throughout the program and causing distant errors. Roc supports and encourages using this technique.
In addition to this, Roc also supports serialization inference. It has some characteristics of both other approaches:
- Like the first technique, it does not require specifying a schema up front.
- Like the second technique, it reports any errors immediately during decoding rather than letting the problems propagate through the program.
This technique works by using Roc's type inference to infer the expected shape of serialized data based on how it's used in your program. Here's an example, using
Decode.fromBytes to decode some JSON:
when Decode.fromBytes data Json.codec is Ok decoded -> # (use the decoded data here) Err err -> # handle the decoding failure
In this example, whether the
Err branch gets taken at runtime is determined by the way the
decoded value is used in the source code.
For example, if
decoded is used like a record with a
username field and an
Serialization inference has a low up-front cost in the same way that the decode-without-validating technique does, but it doesn't have the downside of decoding failures propagating throughout your program to cause distant errors at runtime. (It also works for encoding; there is an Encode.toBytes function which encodes similarly to how
Explicitly writing out a schema has its own benefits that can balance out the extra up-front time investment, but having both techniques available means you can choose whatever will work best for you in a given scenario.
roc test command runs a Roc program's tests. Each test is declared with the
expect keyword, and can be as short as one line. For example, this is a complete test:
## One plus one should equal two. expect 1 + 1 == 2
If the test fails,
roc test will show you the source code of the
expect, along with the values of any named variables inside it, so you don't have to separately check what they were.
If you write a documentation comment right before it (like
## One plus one should equal two here), it will appear in the test output, so you can use that to add some descriptive context to the test if you want to.
You can also use
expect in the middle of functions. This lets you verify assumptions that can't reasonably be encoded in types, but which can be checked at runtime. Similarly to assertions in other languages, these will run not only during normal program execution, but also during your tests—and they will fail the test if any of them fails.
Unlike assertions (and unlike the
crash keyword), failed
expects do not halt the program; instead, the failure will be reported and the program will continue. This means all
expects can be safely removed during
--optimize builds without affecting program behavior—and so
--optimize does remove them. This means you can add inline
expects without having to weigh each one's helpfulness against the performance cost of its runtime check, because they won't have any runtime cost after
--optimize removes them.
In the future, there are plans to add built-in support for benchmarking, generative tests, snapshot tests, simulated I/O (so you don't have to actually run the real I/O operations, but also don't have to change your code to accommodate the tests), and "reproduction replays"—tests generated from a recording of what actually happened during a particular run of your program, which deterministically simulate all the I/O that happened.
Besides being designed to be fast and friendly, Roc is also a functional programming language.
What does functional mean here?