06 The Ridgun Book
Algebraic Data Types (ADTs), Polymorphism, and Why We Don't Want null or Any Here
In 1965, Tony Hoare invented the null reference (null / nil). Forty years later he publicly apologized for it, calling it "my billion-dollar mistake".
Why? Because in languages with null, a function promises in its type signature that it will return a User... and when you least expect it, it hands you null and crashes your production server at three in the morning.
The exact same problem happens with object inheritance and the Any (or Object) type: you put a number and a string into a list [1, "two"], the language shrugs and says "okay, this is now a List[Any], good luck guessing what's inside when you iterate over it".
In Ridgun we cut both problems off at the root using Algebraic Data Types (ADTs) and Parametric Polymorphism. It sounds like a intimidating university course title, but in five minutes you will see that it is the most natural way in the world to model data.
6.1. Product Types (Records): Your Everyday struct, But with Superpowers
When you want to group several pieces of data that belong to the same entity (for example, a server with its hostname, port, and TLS status), you define a type with named fields:
type Server = host: String, port: Int, tls_enabled: Bool
By writing that single line, Ridgun automatically gives you four things for free:
1. The Server constructor
my node => Server
my node = Server "api.example.com" 443 True
2. A pure getter function for every field (host, port, tls_enabled)
You don't write s.getHost() or anything like that; each field name is directly a function that extracts that piece of data:
connection url Server => String
connection url srv = "Connect to " + (host srv) + " on port {port srv}"
3. Functional record update (with field: value)
Imagine a configuration record with 12 or 15 fields (like the flags of an ls command or a web server) and you want to change just one or two fields without touching the rest. Instead of rebuilding the entire record by passing all 15 fields by hand, you use with and colons : (matching the exact syntax of the type definition):
enable dev mode Server => Server
enable dev mode srv = srv with port: 8080, tls_enabled: False
The best of both worlds:
- In your code it is 100% immutable and type-safe (the compiler verifies that
portandtls_enabledexist onServerand have the right types). - Under the hood, if
srvis not shared with anyone else, the native executable reuses its memory in-place (Arc::try_unwrap) without copying the remaining fields.
4. Full pattern matching whenever you want to unpack the record directly
is secure port Server => Bool
is secure port (Server _ 443 True) = True
is secure port _ = False
6.2. Sum Types (Variants with |): How to Retire null, Exceptions, and Class Hierarchies
Now for the really good part.
An ordinary record has host AND port AND tls_enabled.
But in the real world you often need to say: "A payment method is EITHER* a credit card with its number, OR a bank transfer with its IBAN, OR cash (which carries no extra data)"*.
In object-oriented programming you would have to create a PaymentMethod interface, three subclasses in three separate files, constructors, instanceof checks... In Ridgun it is one line:
type PaymentMethod = Card String Int | Transfer String | Cash
And look what happens when you process a PaymentMethod in a function:
describe payment PaymentMethod => String
describe payment (Card num _) = "Card payment ending in {num}"
describe payment (Transfer iban) = "Bank transfer from account {iban}"
describe payment Cash = "Cash payment at the counter"
Each variant (Card, Transfer, Cash) carries only the data that makes sense for it. It is impossible to construct a Cash payment with a dangling iban attached by mistake.
6.3. "So How Do I Put [1, "two"] in a List Without Using Any?"
Let's come back to the million-dollar question. Imagine you are reading a CSV or JSON file where a cell can be an integer, a text string, or empty.
If you had an Any type, you would stuff [1, "two"] into a list and lose all compiler assistance: from that point onward, every operation on the list would be walking blindfolded.
In Ridgun (just like in Haskell or Rust), it takes two seconds to define a sum type that states the exact kinds of values allowed inside that list:
type CellValue = Integer Int | Text String | Empty
format cell CellValue => String
format cell (Integer n) = "Number: {n}"
format cell (Text s) = "Text: {s}"
format cell Empty = "(empty)"
my row => [CellValue]
my row = [Integer 1, Text "two", Empty]
See the difference?
- With
Any, the compiler has no idea whether the list holds integers, strings, or a USB-connected coffee maker. - With
[CellValue], you can freely mix numbers and strings in the same list, while the compiler knows with 100% certainty that only those three cases exist and forces you to unpack each case (Integer n,Text s,Empty) before using it. Total safety without sacrificing an ounce of flexibility.
6.4. Option and Result: When Something Might Be Missing or Might Fail
Because Ridgun has neither null nor uncaught exceptions flying through the call stack (throw new Exception()), the language ships with two built-in universal sum types:
Option: either you haveSome value, or you haveNone(nothing).Result: either the operation succeeded (Ok value), or it failed for a specific reason (Err error).
safe divide Int Int => Result
safe divide _ 0 = Err "Hey! Cannot divide by zero"
safe divide a b = Ok (a / b)
print result Result => ()
print result (Ok n) = echo "The result is {n}"
print result (Err msg) = echo "Handled failure: {msg}"
Nobody can accidentally ignore an error because the Result type itself forces you to check whether there is an Ok or an Err inside.
6.5. Parametric Polymorphism: Generic Functions with A, B, T...
Suppose you write a function that swaps a pair (x, y) or extracts the first element of a list. Does it really matter whether the list holds Int, String, or Server? Not at all! The algorithm is identical.
In Ridgun, any single uppercase letter (A through Z) in a type signature automatically acts as a polymorphic type wildcard (type variable):
safe head [A] A => A
safe head [] default = default
safe head (x:_) _ = x
swap pair (A, B) => (B, A)
swap pair (x, y) = (y, x)
Notice the magic of safe head [A] A => A:
- It is not a lawless
Any: it states that the element type of the list[A], the type of the fallback valuedefault(A), and the return type (A) must all be the exact same type. - If you call
safe head [10, 20] 0, the compiler connects the dots (A = Int) and returns anInt. - If you call
safe head ["a", "b"] "none", it connects the dots (A = String) and returns aString. - But if you try to cheat and call
safe head [10, 20] "none", boom! The compiler stops you cold because the first argument saysAisIntwhile the second saysAisString.
6.6. Create Your Own Operators (op ::): From Vectors to Custom Phrases
Why should built-in numbers be the only types allowed to use + or *? And why settle for standard operators when you can invent the ideal syntax for your domain?
With op :: (or without a prefix when using operator patterns) you can pull off two neat tricks:
1. Teach +, -, *, /, % how to operate on your own types
type Complex = real: Int, imag: Int
op :: Complex + Complex => Complex
a + b = Complex ((real a) + (real b)) ((imag a) + (imag b))
2. Create Mixfix operators (which read like natural phrases, including type and with)
In an operator signature, capitalized words (Int, String, A..Z) or bracketed/parenthesized types ([Int], (A, B)) are the argument slots, and lowercase words become part of the operator phrase (including everyday words like type or with, which are contextual in Ridgun and never get in your way):
op :: when Bool then A else A => A
when True then v else _ = v
when False then _ else f = f
op :: format String type String with Int => String
format label type kind with w = "{label} ({kind}, width={w})"
main => ()
main =
message = when (10 > 5) then "Ten wins!" else "Impossible"
echo message
echo (format "panel" type "bordered" with 80)
You just built your own polymorphic ternary operator and a formatting DSL in 3 lines of Ridgun.
6.7. Shared Constructors, Bare Type Variants, expand type, and Natural Spaces in fn ::
Ridgun makes algebraic data types and functions even more natural and extensible:
- Shared constructor names across types: Multiple
typedeclarations can reuse the same constructor names (Error,Var,Lit) with the same or different arities; the typechecker disambiguates them from the signature column type. - Bare type-name variants without repetition (
type Something = String | Int | MyStruct Float Int): When a sum variant is a built-in type (String,Int,Float/Real,Bool,Char), you do not need to writeInt IntorString String. You can pass bare values ("hello",42) or explicit constructors (String "hello",Int 42), and match both literals ("admin",0) and typed patterns ((String s),(Int n)). - Open type extensions (
expand type) and automaticfn ::clause merging: Useexpand typeto append new variants to a sum type or new fields to a record. Re-declaringfn ::for the same function automatically merges the new pattern clauses before any_catch-all clause (noexpand fnkeyword needed). - Natural spaces
" "instead of_infn ::, built-ins, and filenames: Just likeop ::, multi-wordfn ::names, built-in calls (assert equals,str len,read file), and.rgfilenames (test core language.rg) use natural spaces:
type Something = String | Int | MyStruct Float Int
fn :: describe something Something => String
describe something "admin" = "special_admin"
describe something 0 = "zero"
describe something (String s) = "str:{s}"
describe something (Int n) = "int:{n}"
describe something (MyStruct ratio cnt) = "struct:{ratio}:{cnt}"
describe something _ = "other"
expand type Something = Bool
fn :: describe something Something => String
describe something (Bool b) = "bool:{b}"
5. User-Defined Generic Types (type Result T E, type Page T) and Module Namespaces (import ... as, mod.Type)
Beyond polymorphic functions, you can declare your own generic sum types and generic product records with uppercase type parameters (A..Z), and scope identically named types, constructors, fields, and functions across modules without collision (module ::, import db as d, http.Config, d.Config). Every module has strict 3-tier per-module import visibility: it resolves unqualified names first in (1) its own declarations (including multi-file modules sharing module :: name), next in (2) its direct import modules, and only last in (3) its transitive imports, ensuring sibling modules or the main application never leak into imported libraries:
type Result T E = Ok T | Err E
type Page T = items: [T], total: Int
fn :: unwrap ok (Result A String) A => A
unwrap ok (Ok v) _ = v
unwrap ok (Err _) d = d
In Chapter 07 we will put every core of your processor to work at once without breaking a sweat: higher-order functions (map, ||map, filter, ||filter, fold), lexical closures ((x => expr)), parallel || graphs with ->, and concurrent channels.