types and dispatch
most languages hand you a whole drawer of tools for the question "what kind of thing is this?"—switch, match, instanceof, a type tag you test by hand, a chain of if you read top to bottom. kanso gives you exactly one, and it doubles as the way you build data and take it back apart. get that one tool into your hands and every conditional you were about to write turns into a definition instead. we start with how data goes together.
records
every type in kanso is a single-constructor record: a name and some fields. the fields are alphabetical—enforced, like everything else other languages leave to code review. a field is just a name; its type is whatever the program puts in it and asks of it, which the compiler works out for itself. construction is positional, using the type name as the constructor and passing fields in that same alphabetical order:
writing the type on a field is an error, not a style preference. artist:string gets you a record field carries no type—write artist and let the compiler infer what it holds, because the annotation only repeats what the program already says and then sits there waiting to disagree with it. the language server shows you what a field actually holds when you hover it, which is the same information without a second copy to maintain.
a written type earns its place where it restricts below what use would permit, and that place is a type of its own. type state archived draft published says the set is closed at three, which no amount of reading the program can tell you. that one you write down.
type track
artist
minutes
title
song = track "fishmans" 6 "long season"
track artist minutes title = song
print "{title}, by {artist}—{minutes} minutes"
>> print "or just the one field: {song.title}"
long season, by fishmans—6 minutes
the third line is the other half of the story. you read a record by writing its constructor shape on the left of = and letting every field bind to a name. that is a binding pattern: the same shape you built with, running in reverse.
when one field is all you want, a dot reads it: song.title. the two forms answer different questions. a binding pattern says you are about to use the whole record and want its parts named; a dot says you want one value and are done. reach for the pattern when you would otherwise write three dots in a row.
leave the record out and you have the reader on its own: _.title is a function that takes a track and gives you its title. it goes wherever a function goes.
list/map songs _.title
this works for any record with a title, because a field reader is an ordinary arm and every type that has the field contributes one. nothing has to be imported to read a field — the reader is not a name in your file, so it can never collide with one.
reading is where the dot stops. there is no song.title = "…" outside the one construct in the next section, because a record is a value and a value does not change under you. if you want a track with a different title, you build a track with a different title.
taking a record apart
the positional form binds every field, which is honest but often more than you want. when you only care about a couple of fields, use the keyed read—name the fields you want inside braces, rename freely with :, and leave the rest unmentioned:
type track
artist
minutes
title
song = track "fishmans" 6 "long season"
{ artist:who title } = song
print "{title}, by {who}"
long season, by fishmans
the keyed form is by name, so it needs no field order and no placeholder for the fields it skips. its one rule: a keyed read must omit at least one field—if you wanted all of them you would have used the positional form. if you instead try to split the difference, positional destructuring with _ for the field you would rather not name, the compiler stops you and points at the tool that already does the job:
type track
artist
minutes
title
pub play =
song = track "fishmans" 6 "long season"
track artist _ title = song
print "{title}, by {artist}"
error[syntax]: `_` does not appear in binding patterns; omit fields with a keyed read
--> peek_check.kso:8:16
8 | track artist _ title = song
^
the third place a record comes apart is a function head, and there _ is welcome—a parameter pattern selects the type and unpacks it in one gesture, discarding what it doesn't use:
type track
artist
minutes
title
fn credit (track artist _ title)
"{title}—{artist}"
song = track "fishmans" 6 "long season"
print (credit song)
long season—fishmans
that parameter pattern is also how kanso chooses between overloads, which is the subject of the rest of the chapter.
two records that point at each other
ada's partner is bob and bob's partner is ada. neither can be built first, because each one needs the other to already exist. most languages answer this by making the field nullable and checking it forever after, or by building both halves empty and patching the links in a second pass.
kanso gives the knot its own construct. inside a build block, and nowhere else, a field can be assigned after its record exists:
type person
name
partner
couple = build
ada = person "ada" none
bob = person "bob" ada
ada.partner = bob
ada
print "{couple.name} and {couple.partner.name}"
>> print "back where we started: {couple.partner.partner.name}"
none holds ada's place while she waits for bob, and the assignment on the fourth line closes the loop. when the block ends the whole group freezes: nothing in it can be assigned again. what leaves the block is ordinary immutable data that happens to contain a cycle, so you can walk the ring as far as you like—((couple.partner).partner) is ada again—and no code downstream has to defend against a half-built record, because a half-built record never leaves.
the freeze buys two things. it is what lets the type say partner:person instead of some nullable stand-in, and it is what keeps the memory model simple, which ch10 returns to: everything a block creates is born together and released together, so a cycle costs the runtime no more than a straight line.
the only switch
kanso has no match, no instanceof, no tag test, no narrowing syntax. a value may be one of several types—an int or a string, a result or an err—and the only way to take that apart is to define overloads and let dispatch choose. the conditional you would have written lives in the function head instead. write the same name several times, each with a different pattern, and each call goes to the arm that fits its argument:
fn describe 0
"zero"
fn describe n:int
"the int {n}"
fn describe s:string
"the string {s}"
fn describe x
"something else: {x}"
print (describe 0)
>> print (describe 7)
>> print (describe "seven")
>> print (describe 1.5)
zero
the int 7
the string seven
something else: 1.5
four arms, one name, and a specificity ladder deciding who answers. a literal like 0 is the most specific thing you can ask for, so it wins over the ascribed type n:int, which in turn wins over the bare generic x. the ladder, top to bottom: literal, then single concrete type—a wrapper before the type it wraps—then—we'll get there—typeset, then the unannotated generic that catches everything left. note the ascription is tight: n:int, no spaces. n: int is a formatting error, because the loose spelling is reserved for something the compiler will use later. when an arm needs the type and not the value, ascribe the wildcard: _:int dispatches on membership and binds nothing.
dispatch on a literal is what turns the classic recursive definition into something you can read aloud. the base case is an overload, not an if guarding the real body:
fn fact 0
1
fn fact n
n * fact (n - 1)
answer = fact 20
print "20! = {answer}"
20! = 2432902008176640000
that answer doesn't fit in 64 bits and nobody had to care—int is arbitrary-precision, as chapter 02 promised.
markers: a type with no fields
a record's fields are optional. a type with no field lines at all is a marker: the bare mention of its name is its only value. true, false, and none are markers the standard library declares this way—none of them is a keyword. you declare your own the same way, and dispatch reads them like any other type:
type null
fn describe null
"nothing here"
fn describe x
"got {x}"
print (describe null) >> print (describe 5)
nothing here
got 5
the same word null is the type, the value, and the pattern—there is nothing to disambiguate, because a binding can never be named after a declared type (kanso forbids shadowing). a marker holds nothing, so passing it fields is meaningless, and the compiler says so:
type null
pub play =
x = null 5
print "{x}"
error[signature]: `null` takes no fields; its bare mention is its value
--> markerarg_check.kso:4:7
4 | x = null 5
^
a handful of markers with one dispatch group over them is an enumeration, for free—no enum keyword, no special case, just types and the switch you already have:
type active
type archived
type pending
fn label active
"live"
fn label archived
"in the vault"
fn label pending
"coming soon"
print (label active)
>> print (label archived)
>> print (label pending)
live
in the vault
coming soon
typesets: naming a set of types
a field holds whatever the program puts in it, and the compiler works that out. sometimes you want to say something the program does not otherwise say — that a post is archived, a draft, or published, and never a fourth thing. give the set a name:
type archived
type draft
type published
type state archived draft published
type post
state
title
fn label archived
"archived"
fn label draft
"a draft"
fn label published
"live"
p = post draft "long season"
print "{p.title} is {label p.state}"
long season is a draft
type state archived draft published is a typeset: one line, members alphabetical, no fields of its own. it is a name for "one of these", and once it exists you can dispatch on it, annotate a parameter with it, and read it in an error message. the field itself stays bare — state — because what that field holds is still something the compiler derives from the program.
this is the one place a written type earns its keep. everywhere else the compiler already knows and writing it down only adds a second copy to disagree with the first; here you are saying something it could not have worked out, because a set nobody has yet exceeded looks exactly like a set nobody can exceed.
wrappers: one type standing in for another
a typeset names a set of types you already have. the other direction names a new type that is one you already have: cents are integers, a post body is a string. one line declares it, child first and parent after, and the compiler can then tell them apart where you care and treat them alike where you don't:
type cents int
type post_body string
fn to_string _:cents
"a price"
fn quote c:cents
"{c} for the set"
fn quote n:int
"{n} plain"
print (quote (cents 350))
>> print (quote 350)
>> print "a body reads through: {post_body "hello"}"
a price for the set
350 plain
a body reads through: hello
cents 350 builds one, and the wrapper sits one rung above its parent on the ladder: quote (cents 350) takes the c:cents arm while a bare 350 falls through to n:int. arms still appear most-specific first, so the wrapper's arm is written above the parent's. a chain may run deeper than one link, and the nearer declaration always answers.
a wrapper flows wherever its parent flows, and that includes printing: post_body "hello" renders as hello, because a string is what it is. adding a to_string arm for the type changes that, which is what makes the price above read a price. appendix a explains the rule that permits it — an arm may claim a type its own module declares, which is exactly what a wrapper is.
the ladder is law
because dispatch stands in for every switch you would otherwise write, kanso is strict about how an overload group is laid out. the arms appear most-specific first, so the file reads in the order the dispatcher resolves. write them backwards and it is a formatting error, with the fix stated as the rule:
fn fact n
n * fact (n - 1)
fn fact 0
1
error[formatting]: overloads of `fact` appear most-specific first: literal, then concrete type, then generic
--> backwards_check.kso:4:4
4 | fn fact 0
^
and two arms that dispatch could never tell apart are rejected outright—a switch with two identical cases is a bug wherever it appears:
fn same x
x
fn same y
y
error[dispatch]: overlapping overloads of `same` are illegal
--> twins_check.kso:4:4
4 | fn same y
^
the ladder ranks the rungs against each other, but never two entries on the same rung—two typesets that overlap, two patterns that catch the same value. there is no tie-breaker by declaration order, because order is inert here: the compiler already knows which is more specific, and where it can't tell, that is your bug to resolve, not a coin it will flip for you.
if is for content, dispatch is for shape
none of this abolishes the ordinary yes-or-no decision. when the question is about a single value's content—is this number negative, is this age under thirteen—if is the right tool, and in kanso it is a plain function, if condition then_value else_value, an expression like any other. no statement form, no dangling else, no ternary syntax to argue about:
fn admission age
if (age < 13) "child ticket" "regular ticket"
print (admission 9) >> print (admission 30)
child ticket
regular ticket
because if is an expression that returns a value, it nests where you need a third branch—here the sign of a number, three answers from two decisions about one integer:
print (sign (0 - 4))
>> print (sign 0)
>> print (sign 9)
fn sign n
if (n < 0) "negative" (if (n > 0) "positive" "zero")
negative
zero
positive
a branch sometimes needs a working name on the way to its answer. binding is a statement, and statements need room, so the branch takes the layout form: the condition alone on the if line, else at the same indent, statements beneath each, and every branch ending in the value it answers with:
print (total_line 12000)
>> print (total_line 800)
fn total_line spent
if (10000 < spent)
saved = spent / 10
"big order: {spent} yen, {saved} back in points"
else
"thanks: {spent} yen"
big order: 12000 yen, 1200 back in points
thanks: 800 yen
the two spellings never compete, because the compiler holds the boundary: a branch with no bindings must use the expression form, and a branch that binds must use the block. there is no else if — a third case is a nested if or, nine times out of ten, the dispatch arms the next section argues for.
the line you never want to cross is using if to ask which kind of thing a value is. that question has a better answer, and it is the design center of the whole language. suppose a chat log carries three kinds of event. the shape-question—"which event is this?"—is answered by three arms of one function, each unpacking the record it names:
type joined
who
type left
who
type renamed
after
before
print (render (joined "clay"))
>> print (render (renamed "kanso" "kai"))
>> print (render (left "mugi"))
fn render (joined who)
"{who} joined"
fn render (left who)
"{who} left"
fn render (renamed after before)
"{before} is now {after}"
clay joined
kai is now kanso
mugi left
a fourth kind of event means a fourth render arm, dropped in next to its siblings—and nothing else in the program changes. compare that to the switch-on-a-tag you would grow instead, one that every new case forces you to find and edit again, in every place it appears. the conditional scatters a type's behavior across the codebase; dispatch gathers it onto the type. the rule of taste is short: if is for a decision about one value's content; overloads are for a decision about a value's shape. when you catch yourself asking "which kind of thing is this?", the answer is never a conditional—it is another arm.
fields alphabetical, arms most-specific first, ascription tight as n:int, one blank line between declarations. there is exactly one place everything goes, which means you never have to decide—and neither does the person reading after you.
a note for later: those overload arms are not tag checks at runtime. i resolve dispatch statically, once per instantiation, and a group of literal or marker arms on one parameter collapses into a jump table. chapter 10 has the ir to prove it.
you can now model a domain the kanso way: each kind of thing is a record or a marker, a field that admits absence or variety is a typeset, and every "what kind is this?" is answered by an overload group rather than a conditional. you have seen construction and its two inverses, dispatch on literals, types, and markers, the specificity ladder and the two laws that keep it honest, and the single rule that decides when to reach for if and when to reach for another arm. chapter 04 takes the one type we have kept offstage—err—and shows how the same dispatch machinery makes failure a value that handles itself.
exercises
- define a
temperaturerecord with a numeric field and a marker-typeset field that is eithercelsiusorfahrenheit. write ato_celsiusgroup of overloads that dispatches on the scale marker and returns the value in celsius—noifanywhere. - write a
labelgroup over three markersweekday,weekend, andholiday, then add a fourth marker without touching any existing arm. confirm it compiles and runs, and note how many lines you had to change. - take the
signfunction from this chapter and try to rewrite it as three overloads instead of nestedif. where does dispatch help, and where does it fight you? write a sentence on whysignis a content question, not a shape one. - deliberately trigger three of the chapter's compile errors on purpose: fields out of alphabetical order, overload arms in the wrong specificity order, and two arms dispatch cannot tell apart. read each diagnostic and write the one-line rule it is teaching.
- declare
type meters intandtype feet int, then write onedescribegroup with an arm for each and a third for plainint. call it three ways and confirm each lands where you expect. then delete themetersarm and predict which arm answersmeters 5before you run it.