Error handling

In Geng, a function that can fail says so in its type, and the caller must deal with both outcomes before the code compiles. There is no null, no exception, and no throw, so there is no invisible control flow.

This does not make programs correct: logic bugs are still possible. It means that the ways a function can fail are written down, and the compiler checks that someone handles them.

Missing values: Maybe#

Maybe a is either Just a or Nothing. It replaces None, nil and nullptr.

type Maybe a
    = Just a
    | Nothing

Standard functions that might have no answer return a Maybe:

Array.get : Int -> Array a -> Maybe a
Array.first : Array a -> Maybe a
Dict.get : Ord k => k -> Dict k v -> Maybe v
String.toInt : String -> Maybe Int
Char.fromCode : Int -> Maybe Char

To use the value, match on it:

greeting : Maybe String -> String
greeting maybeName =
    when maybeName is
        Just name ->
            "Hello, " ++ name

        Nothing ->
            "Hello, stranger"

or use one of the helpers:

FunctionDoes
Maybe.withDefault 0 mthe value, or 0 if there is none
Maybe.map f mapply f to the value, if there is one
Maybe.andThen f mapply f, which itself returns a Maybe

Failures: Result#

Result e a is either Ok a or Err e. It replaces exceptions and Go’s (value, err) pairs, and the error can carry information about what went wrong.

type Result error value
    = Ok value
    | Err error

Define your own error type as a custom type, not a string, so callers can match on it:

type ConfigError
    = MissingField String
    | BadPort String


parsePort : String -> Result ConfigError Int
parsePort text =
    when String.toInt text is
        Just port ->
            Ok port

        Nothing ->
            Err (BadPort text)

Helpers:

FunctionDoes
Result.map f rtransform the Ok value
Result.mapError f rtransform the Err value
Result.andThen f rrun another step that can fail
Result.withDefault x rthe Ok value, or x
Result.toMaybe rdrop the error
Result.fromMaybe err mturn Nothing into Err err

Chaining steps that can fail#

andThen runs the next step only if the previous one succeeded. The first error stops the chain and becomes the result:

type alias Config =
    { port : Int }


loadConfig : Dict String String -> Result ConfigError Config
loadConfig fields =
    Dict.get "port" fields
        |> Result.fromMaybe (MissingField "port")
        |> Result.andThen parsePort
        |> Result.map (\port -> { port = port })

Errors from effects#

A Task x a is an effect that either succeeds with an a or fails with an x. Reading a file, calling a server and similar work return a Task, and the same patterns apply:

FunctionDoes
Task.andThenrun the next task if this one succeeded
Task.mapErrorchange the error type
Task.onErrorrecover from an error by running another task
Task.bracketacquire a resource, use it, and always release it
Task.finallyrun cleanup whether the task succeeded, failed or was cancelled

Reaching main#

A program’s main has the type Task Never {}. Never is a type with no values, so a task whose error type is Never cannot fail. That means every error has to be handled before it reaches main, typically by reporting it:

main : Task Never {}
main =
    run
        |> Task.onError (\err -> Console.writeErr (errorToString err))

Things that crash in other languages#

SituationPython / Go / C++Geng
Missing valueNone, nil or null dereferenceMaybe, checked by the compiler
Index out of rangeexception, panic, or undefined behaviorArray.get returns Nothing
Missing Dict keyKeyError, zero value, or inserts a defaultDict.get returns Nothing
Parsing a numberexception, or an error to checkString.toInt returns a Maybe
Unhandled enum casesilent fall-throughcompile error: when must cover every case
Integer overflowbignum, wrap, or undefined behaviorwraps, the same on every platform
Integer division by zeroexception, panic, or undefined behaviorx // 0 is 0
Float division by zeroexception in Python, infinity elsewhereinfinity, as IEEE 754 specifies
Comparing functions with ==allowedcompile error
Forgetting to close a fileleakTask.bracket releases on every path

What can still go wrong#

A few things still end a program. Each one ends it with a message saying what happened, and exit status 1:

  • Running out of stack. Very deep recursion that is not a tail call can exhaust the stack. Tail calls, including calls between a group of local functions, run in constant stack on every backend, so this mostly affects recursion over deep trees. A native program has a 1 GiB stack, enough for ten million frames.
  • Running out of memory. Allocating is treated as something that always succeeds, as in most languages, so a program that runs out of memory ends.
  • A size past a fixed limit. Sizes are Ints, so a String, an Array or Bytes cannot be longer than 2³¹ − 1. A function that would build one longer, such as Bytes.flatten, ends the program and names the limit. Where that can happen, a …Checked form, such as Bytes.flattenChecked, returns a Maybe instead.
  • Debug.todo, which you write yourself for code that is not finished. It names its module and line, and an --optimize build refuses it.

A crash is never how a function reports an ordinary failure. That is what Maybe, Result and Task are for.