DEV Community

Mat Weiss
Mat Weiss

Posted on

From Braces to Pipes

The previous article argued that compilers could check more than they currently do — and that the agentic coding era makes this urgent. The articles that follow demonstrate specific features in Ruuk, a language designed around that idea. But Ruuk's syntax is based on F#, and if you've never seen an ML-family language, the examples will be harder to follow than they need to be.

This article is a warp-speed tour of F# syntax — just enough to read the Ruuk code in the rest of the series. If you've written C#, Java, or TypeScript, nothing here is conceptually alien. The ideas have direct parallels; the notation is just different. And to keep things interesting, we'll model our examples around the operational systems of a certain starship.

Why Fsharp

The choice of F# as Ruuk's syntactic foundation wasn't personal preference. Before I'd decided on paradigm or syntax style, I studied how programmers actually read code — drawing on cognitive linguistics research. Two findings shaped the design. First, people tend to read code the same way they read prose: linearly, left to right, top to bottom. Nested function calls like toUpper(trim(getName(user))) force inside-out reading — you start at the innermost call and work outward. Fluent method chaining — user.getName().trim().toUpper() — solves the reading-order problem, but it ties each step to a method defined on the preceding type. A pipeline like user |> getName |> trim |> toUpper reads in the same order things happen, and the functions are standalone — they compose freely without needing to live on a class. ML-family languages build around this style, and that made them a natural place to start.

Second, natural language encodes relationships through prepositions — from, to, by, in — and humans process these almost reflexively. That observation led directly to Ruuk's parameter roles, which you'll see in the next article: beamUp riker from planetSurface to padOne by laForge reads the way you'd describe the operation out loud. The syntax isn't ML-flavored because ML is elegant (though it is). It's ML-flavored because that family gave Ruuk a strong foundation for readable, linear code.

Values and Functions

F# uses let to bind a name to a value. If you're coming from a C-family language, think const — bindings are immutable by default.

let shipName = "Enterprise"
let registry = "NCC-1701-D"
let maxWarpFactor = 9.6
Enter fullscreen mode Exit fullscreen mode

No type annotations. F# infers types from usage — shipName is a string, maxWarpFactor is a float. You can annotate explicitly, but idiomatic F# lets the compiler do the work:

let shipName: string = "Enterprise"   // valid, but unnecessary here
Enter fullscreen mode Exit fullscreen mode

Functions use the same let keyword. There's no function or func or fn — a binding that takes parameters is a function:

let warpSpeed factor =
    factor * 299792.458

let greeting name rank =
    $"{rank} {name}, reporting for duty."
Enter fullscreen mode Exit fullscreen mode

warpSpeed takes one argument and returns a float. greeting takes two and returns a string. The compiler infers all of it. No return keyword either — the last expression in a function is its return value.

And if you call warpSpeed with the wrong type, the compiler catches it immediately:

warpSpeed "fast"
// Error: This expression was expected to have type 'float'
//        but here has type 'string'
Enter fullscreen mode Exit fullscreen mode

No runtime surprise. The compiler saw that warpSpeed multiplies its argument by a float, inferred the parameter must be a float, and rejected "fast" at compile time.

If you're used to braces and semicolons: whitespace is significant in F#. Indentation defines scope, similar to Python. The body of warpSpeed is indented under its declaration — that's what makes it the body, not a pair of curly braces.

These three properties — immutable bindings, type inference, and functions defined with let — are the foundation everything else in this tour builds on.

Records

The type keyword defines new types. A record is a named collection of fields — the closest analog is a C# record or a TypeScript object literal with a fixed shape:

type CrewMember = {
    Name: string
    Rank: string
    Department: string
    ClearanceLevel: int
}
Enter fullscreen mode Exit fullscreen mode

Creating a record instance looks like this:

let picard = {
    Name = "Jean-Luc Picard"
    Rank = "Captain"
    Department = "Command"
    ClearanceLevel = 10
}
Enter fullscreen mode Exit fullscreen mode

Notice there's no new CrewMember(...). F# infers that picard is a CrewMember because the field names match — only one record type in scope has that exact set of fields. Type inference extends beyond simple values.

Field access uses dot notation:

let name = picard.Name   // "Jean-Luc Picard"
Enter fullscreen mode Exit fullscreen mode

Records are immutable. You don't modify a record — you create a copy with specific fields changed using the with keyword:

let promoted = { picard with Rank = "Admiral" }
Enter fullscreen mode Exit fullscreen mode

picard still has Rank = "Captain". promoted is a separate value with Rank = "Admiral" and everything else copied. If you've used spread syntax in JavaScript ({ ...picard, rank: "Admiral" }), it's the same idea with a compile-time guarantee that the field names and types are correct.

Immutable records with copy-on-write are the default data model in F# — and in Ruuk. You define the shape, the compiler tracks it, and transformations produce new values instead of mutations.

Lists and Pipelines

F# lists use square brackets. Items are separated by semicolons, or by newlines if each item is on its own line:

let bridge = [
    { Name = "Picard"; Rank = "Captain"; Department = "Command"; ClearanceLevel = 10 }
    { Name = "Riker"; Rank = "Commander"; Department = "Command"; ClearanceLevel = 9 }
    { Name = "La Forge"; Rank = "Lt. Commander"; Department = "Engineering"; ClearanceLevel = 8 }
    { Name = "Crusher"; Rank = "Commander"; Department = "Medical"; ClearanceLevel = 8 }
    { Name = "Worf"; Rank = "Lieutenant"; Department = "Security"; ClearanceLevel = 7 }
    { Name = "Data"; Rank = "Lt. Commander"; Department = "Operations"; ClearanceLevel = 8 }
]
Enter fullscreen mode Exit fullscreen mode

F# lists are immutable. Adding an element produces a new list; the original doesn't change.

The real power of lists in F# is how you process them. The pipe operator |> takes the result of the left side and passes it as the last argument to the function on the right. This lets you write data transformations as a top-to-bottom pipeline:

let seniorStaff =
    bridge
    |> List.filter (fun m -> m.ClearanceLevel >= 8)
    |> List.sortByDescending (fun m -> m.ClearanceLevel)
    |> List.map (fun m -> m.Name)
// ["Picard"; "Riker"; "La Forge"; "Crusher"; "Data"]
Enter fullscreen mode Exit fullscreen mode

Read it top to bottom: start with the bridge crew, keep only those with clearance 8 or above, sort by clearance descending, extract their names. Each step feeds into the next.

fun m -> m.ClearanceLevel >= 8 is a lambda — same idea as m => m.ClearanceLevel >= 8 in C# or JavaScript, different arrow. List.filter, List.map, and List.sortByDescending are direct counterparts to LINQ's Where, Select, and OrderByDescending, or Java's filter, map, and sorted.

If you've chained LINQ methods or Java streams, pipelines will feel natural. The difference is that pipes compose standalone functions rather than calling methods on an object. Data flows through; functions don't need to know about each other.

Discriminated Unions

We're at warp now — this is the feature that matters most for the rest of the series.

Records describe things that have multiple fields. Discriminated unions describe things that can be one of several cases. The type keyword does both jobs.

A simple union with no associated data:

type Department =
    | Command
    | Engineering
    | Medical
    | Science
    | Security
    | Operations
Enter fullscreen mode Exit fullscreen mode

Each case is a distinct value. This replaces the Department: string we used earlier in CrewMember — instead of hoping someone types "Engineering" correctly, the compiler knows exactly which values are valid:

type CrewMember = {
    Name: string
    Rank: string
    Department: Department
    ClearanceLevel: int
}

let worf = {
    Name = "Worf"
    Rank = "Lieutenant"
    Department = Security
    ClearanceLevel = 7
}
Enter fullscreen mode Exit fullscreen mode

No quotes around Security — it's a value of type Department, not a string. Try assigning Department = "Security" and the compiler rejects it. Try assigning Department = Tactical and the compiler rejects that too — Tactical isn't one of the cases.

If you're coming from Java or C#, this looks like an enum, and for simple cases it works the same way. The difference is that each case can carry its own data:

type AlertStatus =
    | Green
    | Yellow of reason: string
    | Red of threat: string * shieldsUp: bool
Enter fullscreen mode Exit fullscreen mode

Green carries nothing. Yellow carries a reason. Red carries a threat description and whether shields are raised. These aren't three variations of the same data shape — each case has its own structure. Try modeling this with a C# enum and you'll end up with a class hierarchy or nullable fields. The union makes it one type.

let currentAlert = Yellow "Unidentified vessel on long-range sensors"
let battleStations = Red ("Romulan warbird decloaking", true)
Enter fullscreen mode Exit fullscreen mode

Ruuk's outcomes — which we'll see in the next article — are discriminated unions. When an operation can succeed, fail, or partially fail in domain-specific ways, each outcome is a case with its own data. The compiler knows all of them and can verify you've handled every one.

Option and Result

Two discriminated unions show up so often in F# that they're built into the standard library. Rust has the same pair (Option and Result); if you've used those, this is identical in intent.

Option represents a value that might not exist. It has two cases: Some with a value inside, or None. This replaces null — and unlike null, you can't use the value without first deciding what absence means.

let findCrewMember name =
    bridge |> List.tryFind (fun m -> m.Name = name)

let found = findCrewMember "Worf"          // Some { Name = "Worf"; ... }
let missing = findCrewMember "Kirk"        // None
Enter fullscreen mode Exit fullscreen mode

List.tryFind returns an Option<CrewMember>, not a CrewMember. You can't call .Name on the result directly — the compiler knows it might be None. You have to unwrap it first — which means deciding what happens when nothing is found. No NullReferenceException three layers away from the actual problem.

Result represents an operation that can succeed or fail, with data in both cases: Ok carries the success value, Error carries the failure value.

type TransporterFailure =
    | SignalLost of lastCoords: string
    | PatternDegradation of integrity: float
    | TargetShielded

type TransporterTarget = {
    Name: string
    SignalStrength: float
    LastKnownCoords: string
}

let beamUp target =
    if target.SignalStrength > 0.8 then
        Ok target.Name
    elif target.SignalStrength > 0.4 then
        Error (PatternDegradation target.SignalStrength)
    else
        Error (SignalLost target.LastKnownCoords)
Enter fullscreen mode Exit fullscreen mode

beamUp returns Result<string, TransporterFailure> — either a name on success or a typed failure explaining what went wrong. If you're coming from Java or C#, your instinct might be to throw a SignalLostException, a PatternDegradationException, and so on — one exception class per failure mode. That encodes the failures, but it doesn't make them visible in the function's signature. Callers compile fine whether they catch anything or not. Java's checked exceptions tried to fix this, but the syntactic overhead led to so much catch-and-swallow boilerplate that C#, Kotlin, and most modern Java frameworks chose not to adopt the pattern. With Result, the failure cases live in the return type — they're part of the contract, not a side channel — and the compiler won't let you ignore them.

Ruuk inherits Option and Result from this lineage and extends the idea further. Where F# gives you two slots — Ok or Error — Ruuk's operations declare arbitrary domain-specific outcomes as first-class members of the signature. The next article shows what that looks like.

Pattern Matching

Once you inspect an Option or Result, F# gives you a precise tool for handling its cases: match/with, which branches on the shape of data.

let transportReport result =
    match result with
    | Ok name ->
        $"Transport complete. {name} is aboard."
    | Error (SignalLost coords) ->
        $"Signal lost at {coords}. Dispatching shuttle."
    | Error (PatternDegradation integrity) ->
        $"Pattern integrity at {integrity}. Boosting signal."
    | Error TargetShielded ->
        "Cannot beam through shields. Hailing target."
Enter fullscreen mode Exit fullscreen mode

Each branch matches a specific shape and destructures its data. Error (SignalLost coords) doesn't just check that the result is an error — it checks that it's specifically a SignalLost error and pulls out the coordinates in one step. No casting, no instanceof, no nested if chains.

Here's the important part: the compiler checks that every case is handled. Remove the TargetShielded branch and the compiler warns you:

Incomplete pattern matches on this expression.
For example, the value 'Error TargetShielded' may indicate
a case not covered by the pattern(s).
Enter fullscreen mode Exit fullscreen mode

This is exhaustive checking. The compiler knows every case in the union and verifies you've accounted for all of them. Add a new case to TransporterFailure — say WarpFieldInterference — and every match expression that doesn't handle it gets flagged. F# isn't alone here — Java's switch expressions on sealed types and Rust's match enforce the same guarantee. But F# has had it from the start, and it's one of the language's strongest properties: a closed, compiler-verified contract between the type definition and every piece of code that consumes it. It's the property the rest of this series depends on. When Ruuk declares that an operation has four outcomes, this is the mechanism that ensures every caller handles all four.

What's Next

That's enough F# to read Ruuk. Six concepts: let bindings, records, lists and pipelines, discriminated unions, Option/Result, and pattern matching with exhaustive checking. If you followed the transporter example — a function that returns a typed result, where pattern matching lets the compiler check every handled failure mode — you have the mental model for what comes next.

The next article introduces Ruuk's op keyword and its outcomes block — where these F# foundations meet domain-specific compiler enforcement. The compiler takes it from there.

For deeper F# coverage, Scott Wlaschin's F# for Fun and Profit and Isaac Abraham's Get Programming with F# are both excellent starting points for developers coming from OOP languages.

Ruuk is pre-alpha. If these ideas resonate, follow along on GitHub and weigh in on the discussions.

Top comments (0)