Static Dispatch

Dispatch is where the same call expression can result in a different function being run, depending on the types of its arguments and/or return value. It's a form of ad hoc polymorphism.

Static dispatch is where only types known at compile time affect which function gets run. This is in contrast to dynamic dispatch, which uses runtime information to decide which function gets run.

Roc's only ad hoc polymorphism system is static dispatch, and dynamic dispatch is unsupported by design. A major reason for this is that Roc's static dispatch has no runtime overhead; after compilation, it's exactly as if you had called the function directly. (In contrast, it's impossible to avoid runtime overhead in dynamic dispatch, because it has to process information at runtime to do the dispatch.)

Methods

A method is a function that's associated with a type. You define methods for a nominal type in the .{ … } block after its declaration:

Counter := { value : I64 }.{
    new : () -> Counter
    new = || { value: 0 }

    increment : Counter -> Counter
    increment = |{ value }| { value: value + 1 }
}

Calling Methods

There are two ways to call a method. One is to write the type's name, then ., then the method's name, like any other qualified function call:

counter = Counter.increment(Counter.new())

The other is to write a value, then ., then the method's name, and then the rest of the arguments:

counter = Counter.new().increment()

Here, .increment() looks at the type of the value before the ., sees that it's a Counter, and calls Counter.increment with that value as the first argument. So value.method(a, b) is the same as Type.method(value, a, b), where Type is the type of value.

This is static dispatch: the compiler decides which increment to call based on the type of counter, which it knows at compile time. If you call .increment() on a value whose type has no increment method, you get a compile-time error.

The builtin types work the same way. For example, list.len() calls List.len, and name.is_empty() calls Str.is_empty.

Well-Known Methods

Most method names don't mean anything special to the compiler. However, some of them are used by syntax or by builtin functions. For example, a + b calls a.plus(b), so defining a plus method on your type means you can use + with it.

Here are all the method names that the language or the builtins use:

MethodUsed byDefine it when
to_inspect : T -> StrStr.inspect(value), dbgYou want a custom debug representation.
is_eq : T, T -> Bool==, !=You want values of the type to be comparable for equality.
to_hash : T, Hasher -> HasherDict, Set, and other hash-based APIsYou want values of the type to be usable as dictionary keys or set elements.
plus, minus, times, div_by, div_trunc_by, rem_by+, -, *, /, //, %The type has arithmetic-like operations.
is_lt, is_lte, is_gt, is_gte<, <=, >, >=The type has an ordering.
range_exclusive_to : T, T -> Range(T), range_inclusive_to : T, T -> Range(T)..<, ..=You want range syntax to work with the type.
range_exclusive_from : T, T -> Range(T), range_inclusive_from : T, T -> Range(T)Range.iter_revThe type's ranges can be reversed exactly.
range_iterRange.iter, Range.iter_revThe type's ranges can be iterated.
range_len_if_knownNumeric range constructors, Range.step_byThe type can say how many numbers are in a range (when that fits in a U64).
negate, notUnary -, unary !The type has a negation or complement operation.
from_numeral : Numeral -> Try(T, [InvalidNumeral(Str)])Number literalsYou want number literals to work as values of the type.
from_quote : Str -> Try(T, [BadQuotedBytes(Str)])String literalsYou want string literals to work as values of the type.
from_interpolation : List(Str) -> Try((List(item) -> T), [InvalidInterpolation(Str)])String literals with interpolationYou want interpolated string literals to work as values of the type.
iter : T -> Iter(item)for item in valueYou want for loops to work on the type.
nextEach step of a for loopUsually only Iter needs this; collections define iter instead.
parser_for : encoding -> (state -> Try({ value : T, rest : state }, err))Parsers, such as JSON parsingYou want the type to be parseable from formats like JSON.
encoder_for : encoding -> (T, state -> Try(state, err))Encoders, such as JSON encodingYou want the type to be encodable into formats like JSON.
mapTransforming a tag union's payloadYou want to transform a payload with a pure function.
map!Transforming a tag union's payloadYou want to transform a payload with an effectful function.

These aren't interfaces or traits that a type "implements." A type just has a method with the right name and type, and then code that calls that method works with it. Packages can use any method names they like in the same way, using where clauses.

Compiler-Derived Methods

For some of these methods, the compiler can write the implementation for you, based on the shape of the type: is_eq, to_hash, parser_for, encoder_for, map, and map!.

Structural types (records, tuples, and structural tag unions) get these automatically, whenever all the types inside them support the method too. That's why you can compare two records with ==, or use a tuple as a dictionary key, without doing anything.

Nominal types don't get them automatically, because for a nominal type, the right answer isn't always the structural one. (For example, two values of a Fraction type might be equal even if their numerators are different, like 1/2 and 2/4.) Instead, a nominal type opts in by listing the method with _ as its type and no implementation:

Model := { value : Str }.{
    is_eq : _
    to_hash : _
    parser_for : _
    encoder_for : _
}

This only works for those six method names. Writing a method with _ and no implementation is a compile-time error for any other name (except in platform modules, where it declares a host-provided function).

If you write an implementation instead, that's what gets used. So you can, for example, write your own is_eq while letting the compiler derive to_hash and encoder_for.

A derived method has to cover every field or tag a value could have, including the ones an open type leaves unspecified. So if a function compares values whose type ends in ..others, the other tags have to support equality too, and the function's annotation has to say so in a where clause:

is_nope : [Nope, ..others] -> Bool where [others.is_eq : others, others -> Bool]
is_nope = |value| value == Nope

Without the where clause, someone could call is_nope with a value whose other tags have payloads that can't be compared (like functions), so leaving it out is a compile-time error. (If you leave off the annotation entirely, the compiler infers the where clause for you.)

The derived map and map! methods work on tag unions that have one type parameter appearing directly in a payload. map takes a pure function, while map! takes an effectful one:

Maybe(a) := [Just(a), Nothing].{
    map : _
    map! : _
}

to_inspect

The to_inspect method lets you choose how values of a type look when they're passed to Str.inspect, which is what dbg and test failure reports use:

Color := [Red, Green, Blue].{
    to_inspect : Color -> Str
    to_inspect = |color| match color {
        Red => "Color.Red"
        Green => "Color.Green"
        Blue => "Color.Blue"
    }
}

Now Str.inspect uses Color.to_inspect:

red : Color
red = Red

Str.inspect(red)  # "Color.Red"

Without a to_inspect method, Str.inspect shows the value's structure.

Str.inspect only uses a to_inspect method whose type is exactly T -> Str, where any type parameters of T are plain type variables. For example, Wrap(a) := [W(a)] can use to_inspect : Wrap(a) -> Str, but not to_inspect : Wrap(I64) -> Str, and not one with a where clause. A to_inspect method with any other type is still a method you can call yourself; it's just that Str.inspect won't use it.

To show a value's contents from inside to_inspect, call Str.inspect on them, which works on any type:

Wrap(a) := [W(a)].{
    to_inspect : Wrap(a) -> Str
    to_inspect = |Wrap.W(value)| "Wrap(${Str.inspect(value)})"
}

Equality and Hashing

The is_eq method decides what == and != do:

Point := { x : I64, y : I64 }.{
    is_eq : Point, Point -> Bool
    is_eq = |a, b| a.x == b.x and a.y == b.y
}

expect Point.{ x: 1, y: 2 } == Point.{ x: 1, y: 2 } # calls Point.is_eq

a != b calls a.is_eq(b) and then flips the answer.

The to_hash method feeds a value's data into a Hasher:

to_hash : T, Hasher -> Hasher

Dictionaries and sets use to_hash and is_eq together, so if you write your own is_eq, make sure your to_hash agrees with it: whenever two values are equal, they must feed exactly the same data into the hasher. Otherwise, a dictionary might not find a key that's equal to one it has.

Operators

Operators call methods on their left operand, so any type can support them by defining the right methods:

Vec := { x : I64, y : I64 }.{
    plus : Vec, Vec -> Vec
    plus = |a, b| { x: a.x + b.x, y: a.y + b.y }
}

sum = Vec.{ x: 1, y: 2 } + Vec.{ x: 3, y: 4 } # calls Vec.plus

The result of an arithmetic operator has the left operand's type. The right operand usually has the same type, but it doesn't have to; that's up to the method. For example, it can make sense to multiply a Duration by a plain number:

Duration := { millis : I64 }.{
    times : Duration, I64 -> Duration
    times = |duration, scale| { millis: duration.millis * scale }
}

longer = Duration.{ millis: 10 } * 3

The comparison operators (<, <=, >, >=) call methods that return a Bool, and both operands must have the same type.

The range operators (..< and ..=) call methods that return a Range of the operands' type. All the builtin number types have them, and you can define them for your own types:

PageNum := { num : U32 }.{
    range_exclusive_to : PageNum, PageNum -> Range(PageNum)
    range_exclusive_to = |start, end|
        Range.custom({
            lower: start,
            upper: end,
            step: PageNum.{ num: 1 },
            upper_bound: Exclusive,
            direction: To,
            len_if_known: Unknown,
        })
}

pages : Range(PageNum)
pages = first_page..<last_page

To make that range iterable, PageNum would also define range_iter. Defining the two _from methods makes Range.iter_rev work too; types whose steps can't be reversed exactly (like floating-point numbers) leave those out.

Literal Conversion

A number literal can become a value of any type that has a from_numeral method, whenever that type is what's expected:

Celsius := { degrees : I64 }.{
    from_numeral : Numeral -> Try(Celsius, [InvalidNumeral(Str)])
    from_numeral = |n| match I64.from_numeral(n) {
        Ok(degrees) => Ok(Celsius.{ degrees })
        Err(err) => Err(err)
    }
}

temp : Celsius
temp = 21 # calls Celsius.from_numeral

The Numeral contains the literal's exact digits, so the type can decide which literals it accepts. Since this happens at compile time, returning Err(InvalidNumeral(message)) gives a compile-time error with that message. Custom Number Types goes into more detail.

String literals work the same way, with from_quote:

HttpMethod := [Get, Post, Put, Delete].{
    from_quote : Str -> Try(HttpMethod, [BadQuotedBytes(Str)])
    from_quote = |raw| match raw {
        "GET" => Ok(Get)
        "POST" => Ok(Post)
        "PUT" => Ok(Put)
        "DELETE" => Ok(Delete)
        _ => Err(BadQuotedBytes("expected GET, POST, PUT, or DELETE"))
    }
}

method : HttpMethod
method = "POST" # calls HttpMethod.from_quote

Here, writing method = "PATCH" would give a compile-time error saying expected GET, POST, PUT, or DELETE.

A string literal with interpolations in it, like "<p>Hello, ${name}!</p>", uses from_interpolation. This happens in two stages:

  1. At compile time, from_interpolation gets called with the literal's segments, which are the pieces of text around the interpolations. Here, those are ["<p>Hello, ", "!</p>"].
  2. It returns a function, which gets called each time the program evaluates the string literal. That function receives the interpolated values (here, just name) as a list, and returns the finished value.
Html := [Html(Str)].{
    from_interpolation : List(Str) -> Try((List(Str) -> Html), [InvalidInterpolation(Str)])
    from_interpolation = |segments|
        if segments.any(|segment| segment.contains("<script")) {
            Err(InvalidInterpolation("Html literals can't contain script tags"))
        } else {
            Str.from_interpolation(segments).map_ok(|assemble|
                |values| Html(assemble(values.map(|value| value.replace_each("<", "&lt;")))))
        }
}

page : Str -> Html
page = |name| "<p>Hello, ${name}!</p>"

The reason for having two stages is that the segments are written in the source code, so they can be checked at compile time, just like a from_quote literal can. If from_interpolation returns Err(InvalidInterpolation(message)), you get a compile-time error with that message. The interpolated values, on the other hand, aren't known until the program runs, so the function that handles them can't fail. Instead, it decides how those values get into the result. In this example, Html rejects literals with <script written in them, and escapes any < in the interpolated values.

A literal with n interpolations always has n + 1 segments (some of which may be empty strings). The segments are always Str values, whereas the interpolated values can be whatever type the returned function accepts.

Iteration

A for loop calls the iter method on the value after in. So to make your own type work in for loops, give it an iter method that returns an Iter:

Rows := { items : List(Row) }.{
    iter : Rows -> Iter(Row)
    iter = |rows| rows.items.iter()
}

for row in rows {
    process(row)
}

The loop then repeatedly calls next on the iterator, as described in How Iteration Works. Collection types usually build their iterator using the Iter functions rather than defining next themselves.

Parsing and Encoding

Parsers and encoders call parser_for and encoder_for to find out how to read or write a value of a particular type in a particular format (like JSON).

Structural records, tag unions, lists, sets, dictionaries, and the builtin types all have these already (as long as the format supports them), and nominal types can opt into the derived versions with parser_for : _ and encoder_for : _. You'd write them yourself when you want a type to be represented differently from its structure, or when you want its backing type to stay hidden:

Token := { raw : Str }.{
    parser_for : encoding -> (state -> Try({ value : Token, rest : state }, err))
        where [
            encoding.parse_str : encoding, state -> Try({ value : Str, rest : state }, err),
        ]
    parser_for = |encoding| {
        Encoding : encoding

        |state| {
            parsed = Encoding.parse_str(encoding, state)?
            Ok({ value: Token.{ raw: parsed.value }, rest: parsed.rest })
        }
    }

    encoder_for : encoding -> (Token, state -> Try(state, err))
        where [
            encoding.encode_str : encoding, Str, state -> Try(state, err),
        ]
    encoder_for = |encoding| {
        Encoding : encoding

        |token, state| Encoding.encode_str(encoding, token.raw, state)
    }
}

This Token is parsed from (and encoded as) a plain string, rather than a record with a raw field. (Encoding : encoding is explained in Calling Methods on Type Variables.)

Number Literal Defaulting

When nothing says what type a number literal should be, the compiler picks the first type in this list that works with everything the literal is used for:

Dec, I64, U64, I128, U128, I32, U32, I16, U16, I8, U8, F64, F32

So a plain 5 becomes a Dec (see Defaulting to Dec), but a 5 that's used in a way Dec doesn't support moves on down the list to the first type that does support it.

If picking a default this way makes a function's inferred type more specific than it would otherwise be, the compiler gives a LITERAL DEFAULTED warning. To choose a type yourself, add a type annotation or a suffix (like 5.U64).

Where Clauses

A function that calls a method on a value whose type is a type variable needs to say which methods that type must have. This is what where clauses are for:

show_all : List(a) -> Str where [a.to_str : a -> Str]
show_all = |items| {
    var $out = ""

    for item in items {
        $out = $out.concat(item.to_str()).concat(" ")
    }

    $out
}

The where [a.to_str : a -> Str] clause says that show_all accepts a list of any type a, as long as a has a to_str method with the type a -> Str. Inside the function, that's what makes it possible to call item.to_str(), and at each call site, the compiler checks that the list's element type actually has that method. For example, show_all([1.U8, 2, 3]) works because U8 has a to_str method, whereas show_all([{ x: 1 }]) gives an error because records don't.

Since this is static dispatch, each call site knows exactly which to_str implementation it's using, and the compiled program calls it directly.

If a function calls a method on a type variable, but its annotation doesn't have a where clause listing that method, the compiler reports an error. If the function doesn't have an annotation, the compiler infers the where clause automatically.

A where clause can list multiple constraints, separated by commas, and they can involve different type variables:

convert_all : List(a) -> List(b) where [a.to_b : a -> b, b.is_valid : b -> Bool]

See Where Clauses in the types page for more on the syntax.

Calling Methods on Type Variables

Some methods don't take a value of the type as an argument; for example, a method which creates a new value of the type from scratch. To call one of those on a type variable, first give the type variable an uppercase name by writing UppercaseName : lowercase_type_variable inside the function body. After that, the uppercase name can be used to call the type variable's methods:

make_default : {} -> thing where [thing.default : () -> thing]
make_default = |_| {
    Thing : thing

    Thing.default()
}

Here, Thing.default() calls whichever default method belongs to the type that thing turns out to be at the call site. The parser_for example above uses this technique to call Encoding.parse_str(…).

Aliases

When the same group of where constraints is needed in many places, you can give the group a name with a where alias:

a.Showable : where [a.to_str : a -> Str]

This declares a where alias named Showable. Now, instead of repeating the constraint, you can write a.Showable in a where clause:

show_one : a -> Str where [a.Showable]
show_one = |value| value.to_str()

This means exactly the same thing as where [a.to_str : a -> Str]. Any type that has the required methods satisfies the alias; there's no need to declare that a type "implements" Showable.

A where alias can combine other where aliases, as well as individual method constraints:

a.Comparable : where [a.is_lt : a, a -> Bool]

a.Sortable : where [a.Showable, a.Comparable]

Where aliases can also take parameters, which their constraints can mention:

a.Encodable(fmt) : where [a.encode : a, fmt -> fmt]

encode_twice : a, fmt -> fmt where [a.Encodable(fmt)]
encode_twice = |value, fmt| value.encode(value.encode(fmt))

A where alias describes constraints on a type, not a type itself, so it can only be used inside a where clause. Writing something like describe : Showable -> Str is an error.

Performance

Static dispatch has no runtime cost. By the time your program is compiled, value.method() has been replaced by a direct call to the specific method for value's type, exactly as if you had written Type.method(value) yourself. (And like any other direct call, the compiler can inline it.)

That's also true of functions with where clauses. A function like show_all above gets compiled separately for each type it's used with, and in each version, item.to_str() is a direct call to that type's to_str. There's no table of methods passed around at runtime, which is how some other languages implement this kind of feature.

Derived methods are compiled the same way. A derived is_eq on a record compiles to code that compares each field in turn, much like what you'd write by hand.