Ship

Book · Functions and inference

Functions and inference

Draft

Start a task tracker with branded types, inferred functions, typed errors, and compile-time ensure.

On this page · 12 sections

A task

A task has an ID and a title. Both are text, so if both were String, any function taking (id, title) could be called with the arguments swapped—or would need a labeled ~title argument on every call just to tell two strings apart.

type TaskId = new String creates a distinct branded type backed by String. Title is branded too, and its ensure function runs every time a Title is built: an empty title fails with error :empty_title. A record names the fields of a Task:

type TaskId = new String

type Title = new String #{
  ensure = fn(valuevalue: String) {
    if valuevalue: String == """": String {
      error :empty_title;
    }
  };
}

type Task = {
  id: TaskId;
  title: Title;
}

let trackertracker: Book.YouTube.FunctionsAndInference.Task = Task {
  id = TaskId("task-tracker""task-tracker": String);
  title = Title("Build the task tracker""Build the task tracker": String);
};

let walkthroughwalkthrough: Book.YouTube.FunctionsAndInference.Task = Task {
  id = TaskId("youtube-walkthrough""youtube-walkthrough": String);
  title = Title("Write the YouTube walkthrough""Write the YouTube walkthrough": String);
};

let taskstasks: Vector(Book.YouTube.FunctionsAndInference.Task) = [trackertracker: Book.YouTube.FunctionsAndInference.Task, walkthroughwalkthrough: Book.YouTube.FunctionsAndInference.Task];

Records and new types get inspection, display, and JSON without any extra code. At runtime and on the wire, TaskId and Title are just their inner String.

Values

test✓
  trackertracker: Book.YouTube.FunctionsAndInference.Task;
  Task {
    id = TaskId("task-tracker");
    title = Title("Build the task tracker")
  }  expect ✓Vector.lengthlength: pure fn!('a)(items: Vector(Book.YouTube.FunctionsAndInference.Task)) -> Int(taskstasks: Vector(Book.YouTube.FunctionsAndInference.Task)) == 22: Int;

Write it down

Functions are values bound with let. This one writes everything: the parameter type and the return type. pure fn! means the function has no effects and cannot fail.

Typed Label

test✓
  let typed_labeltyped_label: fn(task: Book.YouTube.FunctionsAndInference.Task) -> String = fn(tasktask: Book.YouTube.FunctionsAndInference.Task: Task) -> String { "${tasktask: Book.YouTube.FunctionsAndInference.Task.idid: Book.YouTube.FunctionsAndInference.TaskId}: ${tasktask: Book.YouTube.FunctionsAndInference.Task.titletitle: Book.YouTube.FunctionsAndInference.Title}" };
  ✓typed_labeltyped_label: fn(task: Book.YouTube.FunctionsAndInference.Task) -> String: pure fn!(Task) -> String;
  expect ✓typed_labeltyped_label: fn(task: Book.YouTube.FunctionsAndInference.Task) -> String(trackertracker: Book.YouTube.FunctionsAndInference.Task) == "task-tracker: Build the task tracker""task-tracker: Build the task tracker": String;

Leave it out

Now delete the types. Reading task.id is enough for Ship to know task is a Task, and a string literal is a String.

let labellabel: fn(task: Book.YouTube.FunctionsAndInference.Task) -> String = fn(tasktask: Book.YouTube.FunctionsAndInference.Task) { "${tasktask: Book.YouTube.FunctionsAndInference.Task.idid: Book.YouTube.FunctionsAndInference.TaskId}: ${tasktask: Book.YouTube.FunctionsAndInference.Task.titletitle: Book.YouTube.FunctionsAndInference.Title}" };

The type is the same one we wrote.

Inferred Label

test✓
  ✓labellabel: fn(task: Book.YouTube.FunctionsAndInference.Task) -> String: pure fn!(Task) -> String;
  expect ✓labellabel: fn(task: Book.YouTube.FunctionsAndInference.Task) -> String(trackertracker: Book.YouTube.FunctionsAndInference.Task) == "task-tracker: Build the task tracker""task-tracker: Build the task tracker": String;

Written as a contract, the declaration Ship inferred is the one we deleted:

let label: pure fn!(task: Task) -> String;

Short functions can use => instead of a block. Comparing task.id == id is enough to infer that id is a TaskId:

let has_idhas_id: fn(task: Book.YouTube.FunctionsAndInference.Task, id: Book.YouTube.FunctionsAndInference.TaskId) -> Bool = (tasktask: Book.YouTube.FunctionsAndInference.Task, idid: Book.YouTube.FunctionsAndInference.TaskId) => tasktask: Book.YouTube.FunctionsAndInference.Task.idid: Book.YouTube.FunctionsAndInference.TaskId == idid: Book.YouTube.FunctionsAndInference.TaskId;

Has Id

test✓
  ✓has_idhas_id: fn(task: Book.YouTube.FunctionsAndInference.Task, id: Book.YouTube.FunctionsAndInference.TaskId) -> Bool: pure fn!(Task, TaskId) -> Bool;
  expect ✓has_idhas_id: fn(task: Book.YouTube.FunctionsAndInference.Task, id: Book.YouTube.FunctionsAndInference.TaskId) -> Bool(trackertracker: Book.YouTube.FunctionsAndInference.Task, TaskId("task-tracker""task-tracker": String));
  expect ✓has_idhas_id: fn(task: Book.YouTube.FunctionsAndInference.Task, id: Book.YouTube.FunctionsAndInference.TaskId) -> Bool(trackertracker: Book.YouTube.FunctionsAndInference.Task, TaskId("cli""cli": String)) == FalseFalse: Bool;

Generic functions

count works for a vector of anything. Written out, it names a type variable 'item for whatever the vector holds. A written binder is part of the function's type.

Typed Count

test✓
  let typed_counttyped_count: fn('item)(items: Vector('item)) -> Int = fn('item)(itemsitems: Vector('a): Vector('item)) -> Int {
    Vector.lengthlength: pure fn!('a)(items: Vector('a)) -> Int(itemsitems: Vector('a))
  };
  ✓typed_counttyped_count: fn('item)(items: Vector('item)) -> Int: pure fn!('item)(Vector('item)) -> Int;
  expect ✓typed_counttyped_count: fn('item)(items: Vector(Book.YouTube.FunctionsAndInference.Task)) -> Int(taskstasks: Vector(Book.YouTube.FunctionsAndInference.Task)) == 22: Int;

Left out, count never looks inside its items, so Ship infers a type variable on its own with no binder to name it. Type variable names don't matter: 'a here is the same as 'item above.

let countcount: fn(items: Vector('a)) -> Int = fn(itemsitems: Vector('a)) { Vector.lengthlength: pure fn!('a)(items: Vector('a)) -> Int(itemsitems: Vector('a)) };

Inferred Count

test✓
  ✓countcount: fn(items: Vector('a)) -> Int: pure fn!(Vector('a)) -> Int;
  expect ✓countcount: fn(items: Vector(Book.YouTube.FunctionsAndInference.Task)) -> Int(taskstasks: Vector(Book.YouTube.FunctionsAndInference.Task)) == 22: Int;
  expect ✓countcount: fn(items: Vector(String)) -> Int(["a""a": String, "b""b": String, "c""c": String]) == 33: Int;

Labeled arguments

A parameter written ~to is passed by name: rename(task, to = ...). { task with title = to } copies the task with one field changed.

Typed Rename

test✓
  let typed_renametyped_rename: fn(task: Book.YouTube.FunctionsAndInference.Task, ~to: Book.YouTube.FunctionsAndInference.Title) -> Book.YouTube.FunctionsAndInference.Task = fn(tasktask: Book.YouTube.FunctionsAndInference.Task: Task, ~toto: Book.YouTube.FunctionsAndInference.Title: Title) -> Task {
    { tasktask: Book.YouTube.FunctionsAndInference.Task with title = toto: Book.YouTube.FunctionsAndInference.Title; }
  };
  ✓typed_renametyped_rename: fn(task: Book.YouTube.FunctionsAndInference.Task, ~to: Book.YouTube.FunctionsAndInference.Title) -> Book.YouTube.FunctionsAndInference.Task: pure fn!(Task, ~to: Title) -> Task;
let renamerename: fn(task: Book.YouTube.FunctionsAndInference.Task, ~to: Book.YouTube.FunctionsAndInference.Title) -> Book.YouTube.FunctionsAndInference.Task = fn(tasktask: Book.YouTube.FunctionsAndInference.Task, ~toto: Book.YouTube.FunctionsAndInference.Title) { { tasktask: Book.YouTube.FunctionsAndInference.Task with title = toto: Book.YouTube.FunctionsAndInference.Title; } };

Inferred Rename

test✓
  ✓renamerename: fn(task: Book.YouTube.FunctionsAndInference.Task, ~to: Book.YouTube.FunctionsAndInference.Title) -> Book.YouTube.FunctionsAndInference.Task: pure fn!(Task, ~to: Title) -> Task;
  let renamedrenamed: Book.YouTube.FunctionsAndInference.Task = renamerename: fn(task: Book.YouTube.FunctionsAndInference.Task, ~to: Book.YouTube.FunctionsAndInference.Title) -> Book.YouTube.FunctionsAndInference.Task(trackertracker: Book.YouTube.FunctionsAndInference.Task, to = Title("Ship the task tracker""Ship the task tracker": String));
  expect ✓renamedrenamed: Book.YouTube.FunctionsAndInference.Task.titletitle: Book.YouTube.FunctionsAndInference.Title == Title("Ship the task tracker""Ship the task tracker": String);
  expect ✓trackertracker: Book.YouTube.FunctionsAndInference.Task.titletitle: Book.YouTube.FunctionsAndInference.Title == Title("Build the task tracker""Build the task tracker": String);

Functions as values

Collection functions take the collection first and the callback in a labeled f = ... slot. |> passes a value into the first positional argument, so a pipeline reads top to bottom.

Labels

test✓
  let labelslabels: fn(tasks: Vector(Book.YouTube.FunctionsAndInference.Task)) -> Vector(String) = fn(taskstasks: Vector(Book.YouTube.FunctionsAndInference.Task)) { taskstasks: Vector(Book.YouTube.FunctionsAndInference.Task) |> Vector.mapmap: pure fn!('a, 'b)(items: Vector(Book.YouTube.FunctionsAndInference.Task) @ 'm, ~f: fn(value: Book.YouTube.FunctionsAndInference.Task @ 'm) -> String) -> Vector(String)(f = labellabel: fn(task: Book.YouTube.FunctionsAndInference.Task) -> String) };
  ✓labelslabels: fn(tasks: Vector(Book.YouTube.FunctionsAndInference.Task)) -> Vector(String): pure fn!(Vector(Task)) -> Vector(String);
  expect ✓labelslabels: fn(tasks: Vector(Book.YouTube.FunctionsAndInference.Task)) -> Vector(String)(taskstasks: Vector(Book.YouTube.FunctionsAndInference.Task)) == [
    "task-tracker: Build the task tracker""task-tracker: Build the task tracker": String,
    "youtube-walkthrough: Write the YouTube walkthrough""youtube-walkthrough: Write the YouTube walkthrough": String,
  ];

Tags

A tag like :bug is a value that needs no declaration. Its type is an open variant: [> :bug ] reads "at least :bug". Tags that flow together join into one row.

Because TaskId is distinct from String, it never silently widens at a function call. Write task.id :> String when passing a TaskId to a String helper (or destructure it with let TaskId(raw) = task.id;):

Loose Kind

test✓
  let loose_kindloose_kind: fn(task: Book.YouTube.FunctionsAndInference.Task) -> [> Book.YouTube.FunctionsAndInference.Kind ] = fn(tasktask: Book.YouTube.FunctionsAndInference.Task) {
    if String.starts_withstarts_with: pure fn!(string: String, needle: String) -> Bool(tasktask: Book.YouTube.FunctionsAndInference.Task.idid: Book.YouTube.FunctionsAndInference.TaskId :> String, "bug-""bug-": String) {
      | :bug
      | :feature
    }
  };
  ✓loose_kindloose_kind: fn(task: Book.YouTube.FunctionsAndInference.Task) -> [> Book.YouTube.FunctionsAndInference.Kind ]: pure fn!(Task) -> [ > :bug | :feature ];
  expect ✓loose_kindloose_kind: fn(task: Book.YouTube.FunctionsAndInference.Task) -> [> Book.YouTube.FunctionsAndInference.Kind ](trackertracker: Book.YouTube.FunctionsAndInference.Task) == :feature;

An open row says "at least", so a match on one needs a _ arm for anything else. Name the exact row and write it as the return type when every tag is known. Then a match covers it with no _.

type Kind = [
  | :bug
  | :feature
]

Marker

test✓
  let kindkind: fn(task: Book.YouTube.FunctionsAndInference.Task) -> Book.YouTube.FunctionsAndInference.Kind = fn(tasktask: Book.YouTube.FunctionsAndInference.Task) -> Kind {
    if String.starts_withstarts_with: pure fn!(string: String, needle: String) -> Bool(tasktask: Book.YouTube.FunctionsAndInference.Task.idid: Book.YouTube.FunctionsAndInference.TaskId :> String, "bug-""bug-": String) {
      | :bug
      | :feature
    }
  };
  ✓kindkind: fn(task: Book.YouTube.FunctionsAndInference.Task) -> Book.YouTube.FunctionsAndInference.Kind: pure fn!(Task) -> Kind;

  let markermarker: fn(task: Book.YouTube.FunctionsAndInference.Task) -> String = fn(tasktask: Book.YouTube.FunctionsAndInference.Task) {
    match kindkind: fn(task: Book.YouTube.FunctionsAndInference.Task) -> Book.YouTube.FunctionsAndInference.Kind(tasktask: Book.YouTube.FunctionsAndInference.Task) {
      | :bug -> "[bug] ${labellabel: fn(task: Book.YouTube.FunctionsAndInference.Task) -> String(tasktask: Book.YouTube.FunctionsAndInference.Task)}"
      | :feature -> "[ ] ${labellabel: fn(task: Book.YouTube.FunctionsAndInference.Task) -> String(tasktask: Book.YouTube.FunctionsAndInference.Task)}"
    }
  };
  let crashcrash: Book.YouTube.FunctionsAndInference.Task = Task {
    id = TaskId("bug-crash""bug-crash": String);
    title = Title("Fix the crash""Fix the crash": String);
  };
  expect ✓markermarker: fn(task: Book.YouTube.FunctionsAndInference.Task) -> String(crashcrash: Book.YouTube.FunctionsAndInference.Task) == "[bug] bug-crash: Fix the crash""[bug] bug-crash: Fix the crash": String;
  expect ✓markermarker: fn(task: Book.YouTube.FunctionsAndInference.Task) -> String(trackertracker: Book.YouTube.FunctionsAndInference.Task) == "[ ] task-tracker: Build the task tracker""[ ] task-tracker: Build the task tracker": String;

Errors

error :tag stops a function with a failure, the way Title's ensure rejects an empty title. The tag goes into the function's type after exn. Written out, that type is part of the signature.

new_task builds a Title from a String it only sees at runtime, so it can fail with :empty_title. Because TaskId and String are different types, it takes (id, title) positionally with no ~title to keep two strings apart. A test body cannot fail, so must turns an expected failure into a crash when the test knows a value is valid.

Typed New Task

test✓
  let typed_new_tasktyped_new_task: fn(id: Book.YouTube.FunctionsAndInference.TaskId, title: String) -> Book.YouTube.FunctionsAndInference.Task, exn [ :empty_title ] = fn(idid: Book.YouTube.FunctionsAndInference.TaskId: TaskId, titletitle: String: String) -> Task, exn [ :empty_title ] {
    Task { id = idid: Book.YouTube.FunctionsAndInference.TaskId; title = Title(titletitle: String); }
  };
  ✓typed_new_tasktyped_new_task: fn(id: Book.YouTube.FunctionsAndInference.TaskId, title: String) -> Book.YouTube.FunctionsAndInference.Task, exn [ :empty_title ]: fn(TaskId, String) -> Task, exn [ :empty_title ];

  let clicli: Book.YouTube.FunctionsAndInference.Task = must typed_new_tasktyped_new_task: fn(id: Book.YouTube.FunctionsAndInference.TaskId, title: String) -> Book.YouTube.FunctionsAndInference.Task, exn [ :empty_title ](TaskId("cli""cli": String), "Build the CLI""Build the CLI": String);
  expect ✓labellabel: fn(task: Book.YouTube.FunctionsAndInference.Task) -> String(clicli: Book.YouTube.FunctionsAndInference.Task) == "cli: Build the CLI""cli: Build the CLI": String;

Left out, the failure is inferred from Title's ensure like everything else. new_task never checks for an empty title itself.

let new_tasknew_task: fn(id: Book.YouTube.FunctionsAndInference.TaskId, title: String) -> Book.YouTube.FunctionsAndInference.Task, exn [ :empty_title ] = fn(idid: Book.YouTube.FunctionsAndInference.TaskId, titletitle: String) { Task { id = idid: Book.YouTube.FunctionsAndInference.TaskId; title = Title(titletitle: String); } };

Inferred New Task

test✓
  ✓new_tasknew_task: fn(id: Book.YouTube.FunctionsAndInference.TaskId, title: String) -> Book.YouTube.FunctionsAndInference.Task, exn [ :empty_title ]: fn(TaskId, String) -> Task, exn [ :empty_title ];

Errors compose

Calling a function that can fail passes its failures along automatically. add raises :duplicate_task itself and calls new_task, which can fail with :empty_title:

let new_task = fn(id, title) { Task { id = id; title = Title(title); } };

So add's row has both. Nothing at the call site marks the propagation.

let addadd: fn(tasks: Vector(Book.YouTube.FunctionsAndInference.Task), id: Book.YouTube.FunctionsAndInference.TaskId, title: String) -> Vector(Book.YouTube.FunctionsAndInference.Task), exn [ :duplicate_task(Book.YouTube.FunctionsAndInference.TaskId) | :empty_title ] = fn(taskstasks: Vector(Book.YouTube.FunctionsAndInference.Task), idid: Book.YouTube.FunctionsAndInference.TaskId, titletitle: String) {
  if Vector.existsexists: pure fn!('a)(items: Vector(Book.YouTube.FunctionsAndInference.Task) @ 'm, ~f: fn(value: Book.YouTube.FunctionsAndInference.Task @ 'm) -> Bool) -> Bool(taskstasks: Vector(Book.YouTube.FunctionsAndInference.Task), f = (tasktask: Book.YouTube.FunctionsAndInference.Task) => has_idhas_id: fn(task: Book.YouTube.FunctionsAndInference.Task, id: Book.YouTube.FunctionsAndInference.TaskId) -> Bool(tasktask: Book.YouTube.FunctionsAndInference.Task, idid: Book.YouTube.FunctionsAndInference.TaskId)) {
    error :duplicate_task(idid: Book.YouTube.FunctionsAndInference.TaskId);
  }
  Vector.pushpush: pure fn!('a)(items: Vector(Book.YouTube.FunctionsAndInference.Task), item: Book.YouTube.FunctionsAndInference.Task) -> Vector(Book.YouTube.FunctionsAndInference.Task)(taskstasks: Vector(Book.YouTube.FunctionsAndInference.Task), new_tasknew_task: fn(id: Book.YouTube.FunctionsAndInference.TaskId, title: String) -> Book.YouTube.FunctionsAndInference.Task, exn [ :empty_title ](idid: Book.YouTube.FunctionsAndInference.TaskId, titletitle: String))
};

Add

test✓
  ✓addadd: fn(tasks: Vector(Book.YouTube.FunctionsAndInference.Task), id: Book.YouTube.FunctionsAndInference.TaskId, title: String) -> Vector(Book.YouTube.FunctionsAndInference.Task), exn [ :duplicate_task(Book.YouTube.FunctionsAndInference.TaskId) | :empty_title ]: fn(Vector(Task), TaskId, String) -> Vector(Task),
    exn [ :duplicate_task(TaskId) | :empty_title ];
  let addedadded: Vector(Book.YouTube.FunctionsAndInference.Task) = must addadd: fn(tasks: Vector(Book.YouTube.FunctionsAndInference.Task), id: Book.YouTube.FunctionsAndInference.TaskId, title: String) -> Vector(Book.YouTube.FunctionsAndInference.Task), exn [ :duplicate_task(Book.YouTube.FunctionsAndInference.TaskId) | :empty_title ](taskstasks: Vector(Book.YouTube.FunctionsAndInference.Task), TaskId("cli""cli": String), "Build the CLI""Build the CLI": String);
  expect ✓countcount: fn(items: Vector(Book.YouTube.FunctionsAndInference.Task)) -> Int(addedadded: Vector(Book.YouTube.FunctionsAndInference.Task)) == 33: Int;

Handling errors

try handles only failures. Handling :duplicate_task removes it from the type and leaves :empty_title for the caller.

Add Once

test✓
  let add_onceadd_once: fn(tasks: Vector(Book.YouTube.FunctionsAndInference.Task), id: Book.YouTube.FunctionsAndInference.TaskId, title: String) -> Vector(Book.YouTube.FunctionsAndInference.Task), exn [ :empty_title ] = fn(taskstasks: Vector(Book.YouTube.FunctionsAndInference.Task), idid: Book.YouTube.FunctionsAndInference.TaskId, titletitle: String) {
    try addadd: fn(tasks: Vector(Book.YouTube.FunctionsAndInference.Task), id: Book.YouTube.FunctionsAndInference.TaskId, title: String) -> Vector(Book.YouTube.FunctionsAndInference.Task), exn [ :duplicate_task(Book.YouTube.FunctionsAndInference.TaskId) | :empty_title ](taskstasks: Vector(Book.YouTube.FunctionsAndInference.Task), idid: Book.YouTube.FunctionsAndInference.TaskId, titletitle: String) {
      | :duplicate_task(_) -> taskstasks: Vector(Book.YouTube.FunctionsAndInference.Task)
    }
  };
  ✓add_onceadd_once: fn(tasks: Vector(Book.YouTube.FunctionsAndInference.Task), id: Book.YouTube.FunctionsAndInference.TaskId, title: String) -> Vector(Book.YouTube.FunctionsAndInference.Task), exn [ :empty_title ]: fn(Vector(Task), TaskId, String) -> Vector(Task), exn [ :empty_title ];

  let unchangedunchanged: Vector(Book.YouTube.FunctionsAndInference.Task) = must add_onceadd_once: fn(tasks: Vector(Book.YouTube.FunctionsAndInference.Task), id: Book.YouTube.FunctionsAndInference.TaskId, title: String) -> Vector(Book.YouTube.FunctionsAndInference.Task), exn [ :empty_title ](taskstasks: Vector(Book.YouTube.FunctionsAndInference.Task), TaskId("task-tracker""task-tracker": String), "Again""Again": String);
  expect ✓countcount: fn(items: Vector(Book.YouTube.FunctionsAndInference.Task)) -> Int(unchangedunchanged: Vector(Book.YouTube.FunctionsAndInference.Task)) == 22: Int;

Handle every tag and nothing is left: every failure becomes a message.

Run

test✓
  let runrun: fn(tasks: Vector(Book.YouTube.FunctionsAndInference.Task), id: Book.YouTube.FunctionsAndInference.TaskId, title: String) -> String = fn(taskstasks: Vector(Book.YouTube.FunctionsAndInference.Task), idid: Book.YouTube.FunctionsAndInference.TaskId, titletitle: String) {
    match addadd: fn(tasks: Vector(Book.YouTube.FunctionsAndInference.Task), id: Book.YouTube.FunctionsAndInference.TaskId, title: String) -> Vector(Book.YouTube.FunctionsAndInference.Task), exn [ :duplicate_task(Book.YouTube.FunctionsAndInference.TaskId) | :empty_title ](taskstasks: Vector(Book.YouTube.FunctionsAndInference.Task), idid: Book.YouTube.FunctionsAndInference.TaskId, titletitle: String) {
      | addedadded: Vector(Book.YouTube.FunctionsAndInference.Task) -> "added ${idid: Book.YouTube.FunctionsAndInference.TaskId} (${countcount: fn(items: Vector(Book.YouTube.FunctionsAndInference.Task)) -> Int(addedadded: Vector(Book.YouTube.FunctionsAndInference.Task))} tasks)"
      | error :duplicate_task(existingexisting: Book.YouTube.FunctionsAndInference.TaskId) -> "${existingexisting: Book.YouTube.FunctionsAndInference.TaskId} already exists"
      | error :empty_title -> "a task needs a title""a task needs a title": String
    }
  };
  ✓runrun: fn(tasks: Vector(Book.YouTube.FunctionsAndInference.Task), id: Book.YouTube.FunctionsAndInference.TaskId, title: String) -> String: fn(Vector(Task), TaskId, String) -> String;
  expect ✓runrun: fn(tasks: Vector(Book.YouTube.FunctionsAndInference.Task), id: Book.YouTube.FunctionsAndInference.TaskId, title: String) -> String(taskstasks: Vector(Book.YouTube.FunctionsAndInference.Task), TaskId("cli""cli": String), "Build the CLI""Build the CLI": String) == "added cli (3 tasks)""added cli (3 tasks)": String;
  expect ✓runrun: fn(tasks: Vector(Book.YouTube.FunctionsAndInference.Task), id: Book.YouTube.FunctionsAndInference.TaskId, title: String) -> String(taskstasks: Vector(Book.YouTube.FunctionsAndInference.Task), TaskId("task-tracker""task-tracker": String), "Again""Again": String) == "task-tracker already exists""task-tracker already exists": String;
  expect ✓runrun: fn(tasks: Vector(Book.YouTube.FunctionsAndInference.Task), id: Book.YouTube.FunctionsAndInference.TaskId, title: String) -> String(taskstasks: Vector(Book.YouTube.FunctionsAndInference.Task), TaskId("cli""cli": String), """": String) == "a task needs a title""a task needs a title": String;

Checked at compile time

tracker and walkthrough build their titles with Title(...), and neither needed must. When the argument is known at compile time, Ship runs ensure while checking the program, so a title that passes is proven and building it cannot fail. Compare new_task, whose title arrives at runtime:

Compile-Time Ensure

test✓
  let cli_taskcli_task: fn(id: Book.YouTube.FunctionsAndInference.TaskId) -> Book.YouTube.FunctionsAndInference.Task = fn(idid: Book.YouTube.FunctionsAndInference.TaskId) { Task { id = idid: Book.YouTube.FunctionsAndInference.TaskId; title = Title("Build the CLI""Build the CLI": String); } };
  ✓cli_taskcli_task: fn(id: Book.YouTube.FunctionsAndInference.TaskId) -> Book.YouTube.FunctionsAndInference.Task: pure fn!(TaskId) -> Task;
  expect ✓labellabel: fn(task: Book.YouTube.FunctionsAndInference.Task) -> String(cli_taskcli_task: fn(id: Book.YouTube.FunctionsAndInference.TaskId) -> Book.YouTube.FunctionsAndInference.Task(TaskId("cli""cli": String))) == "cli: Build the CLI""cli: Build the CLI": String;

A literal that fails ensure, like Title(""), does not compile.

Breaking it on purpose

Each program below is a small change to code from this episode, and each one fails to compile with a single diagnostic.

crates/ship-cli/tests/golden/check-task-tracker/run.t

Inference decided label takes a Task, so a String argument is rejected.

✓
type TaskId = new String

type Task = {
  id: TaskId;
  title: String;
}

let label = fn(task) { "${task.id}: ${task.title}" };
let shown = label("task-tracker");
$ship check wrong-argument.shp
error: type mismatch
  --> wrong-argument.shp:9:19
    |
  9 | let shown = label("task-tracker");
    |                   ^^^^^^^^^^^^^^ expected Task, found String

A branded new type is distinct from its underlying String, so passing a bare String where a TaskId was inferred is rejected.

✓
type TaskId = new String

type Task = {
  id: TaskId;
  title: String;
}

let new_task = fn(id, title) { Task { id = id; title = title; } };
let tracker = new_task("task-tracker", "Build the task tracker");
$ship check unbranded-id.shp
error: type mismatch
  --> unbranded-id.shp:9:24
    |
  9 | let tracker = new_task("task-tracker", "Build the task tracker");
    |                        ^^^^^^^^^^^^^^ expected TaskId, found String

A vector holds one element type; the first element fixes it.

✓
type TaskId = new String

type Task = {
  id: TaskId;
  title: String;
}

let tracker = Task {
  id = TaskId("task-tracker");
  title = "Build the task tracker";
};

let tasks = [tracker, "youtube-walkthrough"];
$ship check mixed-vector.shp
error: type mismatch
  --> mixed-vector.shp:13:23
    |
 13 | let tasks = [tracker, "youtube-walkthrough"];
    |                       ^^^^^^^^^^^^^^^^^^^^^ expected Task, found String

A match on a named exact row must cover every tag.

✓
type Kind = [
  | :bug
  | :feature
]

let marker = fn(kind: Kind) {
  match kind {
    | :bug -> "[bug]"
  }
};
$ship check missing-arm.shp
error: non-exhaustive match
  --> missing-arm.shp:7:3
    |
  7 |   match kind {
    |   ^ missing case: :feature

A named exact row admits only its own tags.

✓
type Kind = [
  | :bug
  | :feature
]

let marker = fn(kind: Kind) {
  match kind {
    | :bug -> "[bug]"
    | :feature -> "[ ]"
  }
};

let shown = marker(:chore);
$ship check unknown-tag.shp
error: type mismatch
  --> unknown-tag.shp:13:20
    |
 13 | let shown = marker(:chore);
    |                    ^^^^^^ expected Kind, found [> :chore ]

Handling a tag the expression cannot raise is an error, so a stale or misspelled arm cannot hide.

✓
let new_title = fn(title) {
  if title == "" {
    error :empty_title;
  }
  title
};

let titled = fn(title) {
  try new_title(title) {
    | :empty_title -> "Untitled"
    | :duplicate_task(_) -> "Duplicate"
  }
};
$ship check impossible-arm.shp
error: impossible error arm
  --> impossible-arm.shp:11:7
    |
 11 |     | :duplicate_task(_) -> "Duplicate"
    |       ^^^^^^^^^^^^^^^^^^ error variant cannot produce `:duplicate_task`

A test body cannot fail, so an unhandled error row is rejected before the test runs.

✓
let new_title = fn(title) {
  if title == "" {
    error :empty_title;
  }
  title
};

test "Titles" {
  expect new_title("") == "";
}
$ship check unhandled-test.shp
error: fallible expression in total function
  --> unhandled-test.shp:9:10
    |
  9 |   expect new_title("") == "";
    |          ^^^^^^^^^^^^^ produces [ :empty_title ]

A constant value that fails its ensure function is rejected at compile time.

✓
type Title = new String #{
  ensure = fn(value) {
    if value == "" {
      error :empty_title;
    }
  };
}

let untitled = Title("");
$ship check invalid-constant.shp
error: construction fails validation with :empty_title
  --> invalid-constant.shp:9:22
    |
  9 | let untitled = Title("");
    |                      ^^ value fails `Title` `ensure` at compile time