I'm building Caffeine, a compiler written in Gleam that compiles service expectations to reliability artifacts. This post documents the learnings from a Sunday morning refactor following a late Saturday night coding session.
Setting the Scene
With a Chris Luno mix on, I am taking a slow walk on my under-desk treadmill writing this post.

Low quality picture of my current setup - MacBook pro, 2 external screens, my wife's ring light, and ofc my Lucy mug
Last night I was working on the Artifact type (as per docs): "an artifact surfaces the standard library or a plugin's interface - it defines the expected values on which the logic operates. An SLO artifact would require thresholds and queries while a systems modelling artifact might require dependency relationships."
In yaml, an example artifacts.yaml looks like:
artifacts:
- name: SLO
version: 0.0.1
base_params:
threshold: Decimal
window_in_days: Optional(Integer)
params:
queries: Dict(String, String)
value: String
As you can see, part of this is a version field which must be a legal semver value.

In order to achieve this, it felt like a smart constructor would be the right solution, so I decided to leverage (for the first time) Gleam's opaque types. Here is a simplified version of my original definition:
pub opaque type Artifact {
Artifact(
name: String,
version: String,
)
}
pub fn make_artifact(
name name: String,
version version: String,
) -> Result(Artifact, String) {
// Fail early if the version isn't valid semver
use _ <- result.try(try_parse_semver_string(version))
Ok(Artifact(name:, version:))
}
fn try_parse_semver_string(version: String) -> Result(Bool, String) {
case string.split(version, ".") {
[major, minor, patch] -> {
let parts = [major, minor, patch]
case list.all(parts, is_int) {
True -> Ok(True)
False -> Error("bad semver")
}
}
_ -> Error("bad semver")
}
}
pub fn is_int(integer_string: String) -> Bool {
string.split(integer_string, "")
|> list.map(fn(digit) {
case digit {
"0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" -> True
_ -> False
}
})
|> list.all(fn(a) { a == True })
}
This snippet has a few parts: the first is the opaque type itself, followed by a smart constructor that will fail if the semver is illegal and then two supporting functions to parse the semver.
We can do significantly better here! With a fresh cup of joe in my Lucy mug, I started up the under-desk treadmill and got to work.
Towards a Better Solution
Thou Shalt Use the Standard Library 🙏
The first rather obvious refactor here is to leverage the standard library. A quick perusal of the gleam/int module docs and I come across parse.
This gets us the first refactor:
pub opaque type Artifact {
Artifact(
name: String,
version: String,
)
}
pub fn make_artifact(
name name: String,
version version: String,
) -> Result(Artifact, String) {
// Fail early if the version isn't valid semver
use _ <- result.try(try_parse_semver_string(version))
Ok(Artifact(name:, version:))
}
fn try_parse_semver_string(version: String) -> Result(Bool, String) {
case string.split(version, ".") {
[major, minor, patch] -> {
case int.parse(major), int.parse(minor), int.parse(patch) {
Ok(_), Ok(_), Ok(_) -> Ok(True)
_, _, _ -> Error("bad semver")
}
}
_ -> Error("bad semver")
}
}
Ok, we're now in a moderately better state, down from 4 functions to 3.
Love Thy Pipe Operator 🪈
The try_parse_semver_string function does however leave a lot to be desired: the parsing within the split feels clunky. Furthermore, even the string.split(version, ".") feels less "gleam-y".
Turns out, we can actually leverage pipe operators to clean this up a ton going from:
case string.split(version, ".") {
[major, minor, patch] -> {
case int.parse(major), int.parse(minor), int.parse(patch) {
Ok(_), Ok(_), Ok(_) -> Ok(True)
_, _, _ -> Error("bad semver")
}
}
_ -> Error("bad semver")
}
to:
case version |> string.split(".") |> list.try_map(int.parse) {
Ok([_, _, _]) -> Ok(True)
_ -> Error("bad semver")
}
Here we move version to the left of string.split to make the expression more "sentence-like". We use list.try_map, which applies int.parse to each dot-delimited string and ensures they all succeed—returning Ok(List(Int)) only if every parse passes, or Error if any fails. Then, as before, we leverage pattern-based matching within the case statement to assert the major.minor.patch structure.
What Colour Be Thy Type 🎨
A famous article from 2015 titled What Color is Your Function by Robert Nystrom details the challenge of explicit asynchronous function labelling. In a similar vein, arguing against boilerplate and code complexity, I'd argue that if you allow for opaqueness creep of your types, you end up in a similarly sad state of needing to know which types are opaque and which are "normal". Now, opaque types aren't infectious like async (you can freely mix them), but the mental overhead and boilerplate tax is real.
Again, consider my type and smart constructor:
pub opaque type Artifact {
Artifact(
name: String,
version: String,
)
}
pub fn make_artifact(
name name: String,
version version: String,
) -> Result(Artifact, String) {
// Fail early if the version isn't valid semver
use _ <- result.try(try_parse_semver_string(version))
Ok(Artifact(name:, version:))
}
Well, to access the innards requires getter and setter functions like this:
pub fn set_name(artifact: Artifact, name: String) -> Artifact {
Artifact(..artifact, name:)
}
pub fn get_name(artifact: Artifact) -> String {
artifact.name
}
pub fn set_version(artifact: Artifact, version: String) -> Result(Artifact, String) {
use _ <- result.try(try_parse_semver_string(version))
Ok(Artifact(..artifact, version:))
}
pub fn get_version(artifact: Artifact) -> String {
artifact.version
}
Before long, I am starting to feel like a Java or Ruby developer back in object-oriented programming land. No shade to those folks, but I came to Gleam for the functional bliss.
Instead of making the entire type opaque, we can limit to just the subset which really needs it. Thanks to Gears on Discord for setting me straight!
So we'd end up with something like this:
pub type Artifact {
Artifact(
name: String,
version: Semver,
)
}
pub opaque type Semver {
Semver(version: String)
}
pub fn make_semver(
version version: String,
) -> Result(Semver, String) {
// Fail early if the version isn't valid semver
use _ <- result.try(try_parse_semver_string(version))
Ok(Semver(version:))
}
And furthermore, we really don't even need the opaque type. qua_qua_qua challenged me on Discord to just use a more sane type definition:
pub type Semver {
Semver(major: Int, minor: Int, patch: Int)
}
The beauty of this is that when Caffeine parses the semver, it can use the "smart constructor" but otherwise, we can stay opaque free, avoiding opaqueness creep and any OOP nonsense.
Our final version:
pub type Artifact {
Artifact(name: String, version: Semver)
}
pub type Semver {
Semver(major: Int, minor: Int, patch: Int)
}
pub fn make_semver(version version: String) -> Result(Semver, String) {
case version |> string.split(".") |> list.try_map(int.parse) {
Ok([major, minor, patch]) -> Ok(Semver(major:, minor:, patch:))
_ -> Error("bad semver")
}
}
Minor Notes:
Instead of
Result(Semver, String)we'd very likely introduce an error type, such asSemverErrorwith a variant calledInvalidFormat.The above still isn't completely correct since
int.parseaccepts negative numbers, meaning"1.-2.3"would be valid...
However, this is really a thing of beauty compared to where we started (and this doesn't even show the bonus downstream cleanup since we don't need any more getters or setters...).
