Ship

Book · Modules

Modules

Draft

Graduate Title to a module struct, hide TaskId with abstract type, and build catalogs with signatures and functors.

On this page · 10 sections

Modules as namespaces

Last episode, Title and its ensure sat at the top level next to everything else, and any helper we write for titles would land there too.

A module groups related names. This first draft of a Title module keeps the type next to its helpers. It sits inside Draft so the finished version below can keep the name Title:

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

    let lengthlength: fn(title: Book.YouTube.Modules.Draft.Title.Title) -> Int = fn(titletitle: Book.YouTube.Modules.Draft.Title.Title: Title) { String.char_countchar_count: pure fn!(string: String) -> Int(titletitle: Book.YouTube.Modules.Draft.Title.Title :> String) };
  }
}

It works, but every mention stutters: the type is Title.Title, and building one is Title.Title(...).

Namespace Module

test✓
  let titletitle: Book.YouTube.Modules.Draft.Title.Title = Draft.Title.Title("Build the task tracker""Build the task tracker": String);
  ✓titletitle: Book.YouTube.Modules.Draft.Title.Title: Draft.Title.Title;
  expect ✓Draft.Title.lengthlength: fn(title: Book.YouTube.Modules.Draft.Title.Title) -> Int(titletitle: Book.YouTube.Modules.Draft.Title.Title) == 2222: Int;

A module that is also a type

When a module is built around one type, write module struct and name that type t. type t is reserved for module struct—a plain module cannot declare type t, and a module struct must declare it—so the module header always tells you whether the module name is also a type.

Inside Title, the type is t. Outside, Title is both the type and the module: Title(...) builds one, Title.length calls a helper, and Title.t is rejected.

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

  let lengthlength: fn(title: Book.YouTube.Modules.Title) -> Int = fn(titletitle: Book.YouTube.Modules.Title: t) { String.char_countchar_count: pure fn!(string: String) -> Int(titletitle: Book.YouTube.Modules.Title :> String) };
}

The ensure rule moved with the type, so a constant title is still proven at compile time and needs no must:

Struct Module

test✓
  let titletitle: Book.YouTube.Modules.Title = Title("Build the task tracker""Build the task tracker": String);
  ✓titletitle: Book.YouTube.Modules.Title: Title;
  expect ✓Title.lengthlength: fn(title: Book.YouTube.Modules.Title) -> Int(titletitle: Book.YouTube.Modules.Title) == 2222: Int;

Files are modules

Every .shp file is already a module named after the file—this chapter is the module Book.YouTube.Modules. Moving the body of module struct Title into src/Title.shp gives callers the same Title with nothing else changed: a file that declares type t is a struct module, and folders nest, so src/Tracker/Title.shp is Tracker.Title. Module signatures builds a small project laid out this way.

Hiding a representation with abstract type

TaskId is a String today, but we're not sure it will stay one: it may become a database Int or a UUID. If callers build TaskId("...") or widen id :> String, each of them breaks when that changes.

abstract type keeps the representation private. Inside TaskId, t is a String and the helpers use it as one. Outside, a TaskId can only be made and examined through the module's functions, so changing the representation later touches only this module:

module struct TaskId {
  abstract type t = String

  let of_stringof_string: fn(raw: String) -> Book.YouTube.Modules.TaskId = (rawraw: String: String) -> t => rawraw: String;
  let is_bugis_bug: fn(id: Book.YouTube.Modules.TaskId) -> Bool = (idid: Book.YouTube.Modules.TaskId: t) => String.starts_withstarts_with: pure fn!(string: String, needle: String) -> Bool(idid: Book.YouTube.Modules.TaskId, "bug-""bug-": String);
}

Abstract Type

test✓
  let idid: Book.YouTube.Modules.TaskId = TaskId.of_stringof_string: fn(raw: String) -> Book.YouTube.Modules.TaskId("bug-crash""bug-crash": String);
  ✓idid: Book.YouTube.Modules.TaskId: TaskId;
  expect ✓TaskId.is_bugis_bug: fn(id: Book.YouTube.Modules.TaskId) -> Bool(idid: Book.YouTube.Modules.TaskId);
  expect ✓"${idid: Book.YouTube.Modules.TaskId}" == "bug-crash""bug-crash": String;

Putting Task together

Task is a module struct too, built from the two modules above. It no longer needs a new function: Title already rejects empty titles, so a Task literal is the whole constructor.

module struct Task {
  type t = {
    id: TaskId;
    title: Title;
  }

  type Kind = [
    | :bug
    | :feature
  ]

  let labellabel: fn(task: Book.YouTube.Modules.Task) -> String = fn(tasktask: Book.YouTube.Modules.Task) { "${tasktask: Book.YouTube.Modules.Task.idid: Book.YouTube.Modules.TaskId}: ${tasktask: Book.YouTube.Modules.Task.titletitle: Book.YouTube.Modules.Title}" };
  let renamerename: fn(task: Book.YouTube.Modules.Task, ~to: Book.YouTube.Modules.Title) -> Book.YouTube.Modules.Task = fn(tasktask: Book.YouTube.Modules.Task, ~toto: Book.YouTube.Modules.Title) { { tasktask: Book.YouTube.Modules.Task with title = toto: Book.YouTube.Modules.Title; } };

  let kindkind: fn(task: Book.YouTube.Modules.Task) -> Book.YouTube.Modules.Task.Kind = fn(tasktask: Book.YouTube.Modules.Task) -> Kind {
    if TaskId.is_bugis_bug: fn(id: Book.YouTube.Modules.TaskId) -> Bool(tasktask: Book.YouTube.Modules.Task.idid: Book.YouTube.Modules.TaskId) {
      | :bug
      | :feature
    }
  };
}

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

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

Inference works inside Task the same way it did at the top level. Reading task.id and task.title tells Ship that task is Task, and Task.Kind names the secondary type declared inside it:

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

Task Module

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

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

  ✓Task.kindkind: fn(task: Book.YouTube.Modules.Task) -> Book.YouTube.Modules.Task.Kind: pure fn!(Task) -> Task.Kind;
  let crashcrash: Book.YouTube.Modules.Task = Task {
    id = TaskId.of_stringof_string: fn(raw: String) -> Book.YouTube.Modules.TaskId("bug-crash""bug-crash": String);
    title = Title("Fix the crash""Fix the crash": String);
  };
  expect ✓Task.kindkind: fn(task: Book.YouTube.Modules.Task) -> Book.YouTube.Modules.Task.Kind(trackertracker: Book.YouTube.Modules.Task) == :feature;
  expect ✓Task.kindkind: fn(task: Book.YouTube.Modules.Task) -> Book.YouTube.Modules.Task.Kind(crashcrash: Book.YouTube.Modules.Task) == :bug;

A second domain type

Our repo tracker tracks open design questions alongside tasks. Questions are identified by a number (#108) rather than a TaskId, so they live in their own module struct:

module struct Question {
  type t = {
    number: Int;
    title: String;
  }

  let labellabel: fn(question: Book.YouTube.Modules.Question) -> String = fn(questionquestion: Book.YouTube.Modules.Question) { "#${questionquestion: Book.YouTube.Modules.Question.numbernumber: Int}: ${questionquestion: Book.YouTube.Modules.Question.titletitle: String}" };
}

Question Module

test✓
  let modulesmodules: Book.YouTube.Modules.Question = Question { number = 108108: Int; title = "Module signatures""Module signatures": String; };
  ✓Question.labellabel: fn(question: Book.YouTube.Modules.Question) -> String: pure fn!(Question) -> String;
  expect ✓Question.labellabel: fn(question: Book.YouTube.Modules.Question) -> String(modulesmodules: Book.YouTube.Modules.Question) == "#108: Module signatures""#108: Module signatures": String;

Signatures with module struct members

Task is keyed by a TaskId; Question is keyed by an Int number. Both need the same catalog operations: checking whether a key is already taken, adding an item without duplicates, and rendering labels.

module type defines the contract a module must satisfy. Because a struct module is also its primary type, declaring module struct Key; and module struct Item; inside a signature names both the module members and the types used by key and label—with no with type equality ceremony:

module type ItemSource {
  module struct Key;
  module struct Item;

  key: pure fn!(item: Item) -> Key;
  label: pure fn!(item: Item) -> String;
}

To adapt Task and Question to ItemSource, each adapter aliases Key and Item to the concrete struct modules:

module TaskSource {
  module Key = TaskId;
  module Item = Task;

  let keykey: fn(task: Book.YouTube.Modules.Task) -> Book.YouTube.Modules.TaskId = (tasktask: Book.YouTube.Modules.Task) => tasktask: Book.YouTube.Modules.Task.idid: Book.YouTube.Modules.TaskId;
  let labellabel: fn(task: Book.YouTube.Modules.Task) -> String = Task.labellabel: fn(task: Book.YouTube.Modules.Task) -> String;
}

Task Source

test✓
  expect ✓TaskSource.keykey: fn(task: Book.YouTube.Modules.Task) -> Book.YouTube.Modules.TaskId(trackertracker: Book.YouTube.Modules.Task) == TaskId.of_stringof_string: fn(raw: String) -> Book.YouTube.Modules.TaskId("task-tracker""task-tracker": String);
  expect ✓TaskSource.labellabel: fn(task: Book.YouTube.Modules.Task) -> String(trackertracker: Book.YouTube.Modules.Task) == "task-tracker: Build the task tracker""task-tracker: Build the task tracker": String;
module QuestionSource {
  module Key = Int;
  module Item = Question;

  let keykey: fn(question: Book.YouTube.Modules.Question) -> Int = (questionquestion: Book.YouTube.Modules.Question) => questionquestion: Book.YouTube.Modules.Question.numbernumber: Int;
  let labellabel: fn(question: Book.YouTube.Modules.Question) -> String = Question.labellabel: fn(question: Book.YouTube.Modules.Question) -> String;
}

Question Source

test✓
  let modulesmodules: Book.YouTube.Modules.Question = Question { number = 108108: Int; title = "Module signatures""Module signatures": String; };
  expect ✓QuestionSource.keykey: fn(question: Book.YouTube.Modules.Question) -> Int(modulesmodules: Book.YouTube.Modules.Question) == 108108: Int;
  expect ✓QuestionSource.labellabel: fn(question: Book.YouTube.Modules.Question) -> String(modulesmodules: Book.YouTube.Modules.Question) == "#108: Module signatures""#108: Module signatures": String;

Functors

A functor is a module parameterized by other modules—the name comes from OCaml, where a functor is a function from modules to modules, conventionally prefixed with Make. MakeCatalog takes any S: ItemSource and builds a catalog around Vector(S.Item).

Notice error :duplicate(key) inside add: key has type S.Key, so the inferred error row is exn [ :duplicate(S.Key) ]. Each functor application substitutes S.Key with that catalog's concrete key type.

module struct MakeCatalog(S: ItemSource) {
  type t = Vector(S.Item)

  let empty: t = [];

  let has = fn(itemsitems: Vector(S.Item): t, keykey: S.Key: S.Key) {
    Vector.existsexists: pure fn!('a)(items: Vector(S.Item) @ 'm, ~f: fn(value: S.Item @ 'm) -> Bool) -> Bool(itemsitems: Vector(S.Item), f = (itemitem: S.Item) => S.keykey: pure fn!(item: S.Item) -> S.Key(itemitem: S.Item) == keykey: S.Key)
  };

  let add = fn(itemsitems: Vector(S.Item): t, itemitem: S.Item: S.Item) -> t {
    let keykey: S.Key = S.keykey: pure fn!(item: S.Item) -> S.Key(itemitem: S.Item);
    if hashas: fn(items: Vector(S.Item), key: S.Key) -> Bool(itemsitems: Vector(S.Item), keykey: S.Key) {
      error :duplicate(keykey: S.Key);
    }
    Vector.pushpush: pure fn!('a)(items: Vector(S.Item), item: S.Item) -> Vector(S.Item)(itemsitems: Vector(S.Item), itemitem: S.Item)
  };

  let labels = fn(itemsitems: Vector(S.Item): t) { Vector.mapmap: pure fn!('a, 'b)(items: Vector(S.Item) @ 'm, ~f: fn(value: S.Item @ 'm) -> String) -> Vector(String)(itemsitems: Vector(S.Item), f = S.labellabel: pure fn!(item: S.Item) -> String) };
}

Applying MakeCatalog to TaskSource produces Tasks. Because MakeCatalog is a module struct, Tasks is both the catalog module and the catalog type, and Tasks.add carries exn [ :duplicate(TaskId) ]:

module Tasks = MakeCatalog(TaskSource);

Task Catalog

test✓
  ✓Tasks.addadd: fn(items: Vector(Book.YouTube.Modules.Task), item: Book.YouTube.Modules.Task) -> Vector(Book.YouTube.Modules.Task), exn [ :duplicate(Book.YouTube.Modules.TaskId) ]: fn(Tasks, Task) -> Tasks, exn [ :duplicate(TaskId) ];
  let catalogcatalog: Vector(Book.YouTube.Modules.Task) = must Tasks.addadd: fn(items: Vector(Book.YouTube.Modules.Task), item: Book.YouTube.Modules.Task) -> Vector(Book.YouTube.Modules.Task), exn [ :duplicate(Book.YouTube.Modules.TaskId) ](Tasks.emptyempty: Vector(Book.YouTube.Modules.Task), trackertracker: Book.YouTube.Modules.Task);
  let catalogcatalog: Vector(Book.YouTube.Modules.Task) = must Tasks.addadd: fn(items: Vector(Book.YouTube.Modules.Task), item: Book.YouTube.Modules.Task) -> Vector(Book.YouTube.Modules.Task), exn [ :duplicate(Book.YouTube.Modules.TaskId) ](catalogcatalog: Vector(Book.YouTube.Modules.Task), walkthroughwalkthrough: Book.YouTube.Modules.Task);
  expect ✓Tasks.hashas: fn(items: Vector(Book.YouTube.Modules.Task), key: Book.YouTube.Modules.TaskId) -> Bool(catalogcatalog: Vector(Book.YouTube.Modules.Task), TaskId.of_stringof_string: fn(raw: String) -> Book.YouTube.Modules.TaskId("task-tracker""task-tracker": String));
  expect ✓Tasks.labelslabels: fn(items: Vector(Book.YouTube.Modules.Task)) -> Vector(String)(catalogcatalog: Vector(Book.YouTube.Modules.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,
  ];

  let duplicateduplicate: String = match Tasks.addadd: fn(items: Vector(Book.YouTube.Modules.Task), item: Book.YouTube.Modules.Task) -> Vector(Book.YouTube.Modules.Task), exn [ :duplicate(Book.YouTube.Modules.TaskId) ](catalogcatalog: Vector(Book.YouTube.Modules.Task), trackertracker: Book.YouTube.Modules.Task) {
    | _ -> "added""added": String
    | error :duplicate(idid: Book.YouTube.Modules.TaskId) -> "duplicate id: ${idid: Book.YouTube.Modules.TaskId}"
  };
  expect ✓duplicateduplicate: String == "duplicate id: task-tracker""duplicate id: task-tracker": String;

Applying the same functor to QuestionSource produces Questions, whose add function carries exn [ :duplicate(Int) ]:

module Questions = MakeCatalog(QuestionSource);

Question Catalog

test✓
  let q108q108: Book.YouTube.Modules.Question = Question { number = 108108: Int; title = "Module signatures""Module signatures": String; };

  ✓Questions.addadd: fn(items: Vector(Book.YouTube.Modules.Question), item: Book.YouTube.Modules.Question) -> Vector(Book.YouTube.Modules.Question), exn [ :duplicate(Int) ]: fn(Questions, Question) -> Questions, exn [ :duplicate(Int) ];
  let catalogcatalog: Vector(Book.YouTube.Modules.Question) = must Questions.addadd: fn(items: Vector(Book.YouTube.Modules.Question), item: Book.YouTube.Modules.Question) -> Vector(Book.YouTube.Modules.Question), exn [ :duplicate(Int) ](Questions.emptyempty: Vector(Book.YouTube.Modules.Question), q108q108: Book.YouTube.Modules.Question);
  expect ✓Questions.hashas: fn(items: Vector(Book.YouTube.Modules.Question), key: Int) -> Bool(catalogcatalog: Vector(Book.YouTube.Modules.Question), 108108: Int);
  expect ✓Questions.labelslabels: fn(items: Vector(Book.YouTube.Modules.Question)) -> Vector(String)(catalogcatalog: Vector(Book.YouTube.Modules.Question)) == ["#108: Module signatures""#108: Module signatures": String];

  let duplicateduplicate: String = match Questions.addadd: fn(items: Vector(Book.YouTube.Modules.Question), item: Book.YouTube.Modules.Question) -> Vector(Book.YouTube.Modules.Question), exn [ :duplicate(Int) ](catalogcatalog: Vector(Book.YouTube.Modules.Question), q108q108: Book.YouTube.Modules.Question) {
    | _ -> "added""added": String
    | error :duplicate(numbernumber: Int) -> "duplicate question: #${numbernumber: Int}"
  };
  expect ✓duplicateduplicate: String == "duplicate question: #108""duplicate question: #108": String;

Applicative functor identity

Ship functors are applicative: applying the same functor to the same module path always names the same type. If another module instantiates MakeCatalog(TaskSource) under a second alias, values flow between the two without wrappers or conversions.

module MoreTasks = MakeCatalog(TaskSource);

Applicative Identity

test✓
  let catalogcatalog: Vector(Book.YouTube.Modules.Task): Tasks = must Tasks.addadd: fn(items: Vector(Book.YouTube.Modules.Task), item: Book.YouTube.Modules.Task) -> Vector(Book.YouTube.Modules.Task), exn [ :duplicate(Book.YouTube.Modules.TaskId) ](Tasks.emptyempty: Vector(Book.YouTube.Modules.Task), trackertracker: Book.YouTube.Modules.Task);
  let count_taskscount_tasks: fn(catalog: Vector(Book.YouTube.Modules.Task)) -> Int = fn(catalogcatalog: Vector(Book.YouTube.Modules.Task): MoreTasks) { Vector.lengthlength: pure fn!('a)(items: Vector(Book.YouTube.Modules.Task)) -> Int(catalogcatalog: Vector(Book.YouTube.Modules.Task)) };
  expect ✓count_taskscount_tasks: fn(catalog: Vector(Book.YouTube.Modules.Task)) -> Int(catalogcatalog: Vector(Book.YouTube.Modules.Task)) == 11: Int;

Breaking it on purpose

Outside TaskId, a TaskId is not a String, even though it is one inside the module.

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

Outside its module, an abstract TaskId is not a String.

✓
module struct TaskId {
  abstract type t = String

  let of_string = (raw: String) -> t => raw;
}

let is_bug = fn(id: TaskId) { String.starts_with(id, "bug-") };
$ship check abstract-task-id.shp
error: type mismatch
  --> abstract-task-id.shp:7:50
    |
  7 | let is_bug = fn(id: TaskId) { String.starts_with(id, "bug-") };
    |                                                  ^^ expected String, found TaskId