Ship

Book · Rest parameters and spreads

Rest parameters and spreads

Draft

Collect extra call labels into a sparse record and forward a record's fields as labels.

On this page · 7 sections

Collecting labels

A rest parameter ..name: Record comes last and has a sparse record type. Every call label that is not another parameter becomes a field of that record, so the record's fields are the extra labels the function accepts. A field the call leaves out is None.

type ButtonAttrs = sparse {
  class: String;
  disabled: Bool;
}

let buttonbutton: fn(label: String, ..attrs: Book.Records.RestParameters.ButtonAttrs) -> String = fn(labellabel: String: String, ..attrsattrs: Book.Records.RestParameters.ButtonAttrs: ButtonAttrs) -> String {
  let classclass: String = attrsattrs: Book.Records.RestParameters.ButtonAttrs.classclass: Option(String) ?> "plain""plain": String;
  let statestate: String = if attrsattrs: Book.Records.RestParameters.ButtonAttrs.disableddisabled: Option(Bool) == SomeSome: fn(Bool) -> Option(Bool)(TrueTrue: Bool) {
    | "off""off": String
    | "on""on": String
  };
  labellabel: String ++ "/""/": String ++ classclass: String ++ "/""/": String ++ statestate: String
};

Collect Labels

test✓
  ✓buttonbutton: fn(label: String, ..attrs: Book.Records.RestParameters.ButtonAttrs) -> String: pure fn!(String, ..attrs: ButtonAttrs) -> String;
  expect ✓buttonbutton: fn(label: String, ..attrs: Book.Records.RestParameters.ButtonAttrs) -> String("save""save": String, class = "wide""wide": String, disabled = TrueTrue: Bool) == "save/wide/off""save/wide/off": String;
  expect ✓buttonbutton: fn(label: String, ..attrs: Book.Records.RestParameters.ButtonAttrs) -> String("save""save": String, disabled = TrueTrue: Bool) == "save/plain/off""save/plain/off": String;
  expect ✓buttonbutton: fn(label: String, ..attrs: Book.Records.RestParameters.ButtonAttrs) -> String("save""save": String) == "save/plain/on""save/plain/on": String;

Each label is checked against its field's declared type. A rest record may be generic; write its full type arguments.

type Handlers('msg) = sparse {
  on_click: 'msg;
  on_hover: 'msg;
}

let clickedclicked: fn(label: String, ..handlers: Book.Records.RestParameters.Handlers('a)) -> Option('a) = fn(labellabel: String: String, ..handlershandlers: Book.Records.RestParameters.Handlers('a): Handlers('msg)) -> Option('msg) {
  handlershandlers: Book.Records.RestParameters.Handlers('a).on_clickon_click: Option('a)
};

Generic Rest

test✓
  ✓clickedclicked: fn(label: String, ..handlers: Book.Records.RestParameters.Handlers('a)) -> Option('a): pure fn!(String, ..handlers: Handlers('msg)) -> Option('msg);
  expect ✓clickedclicked: fn(label: String, ..handlers: Book.Records.RestParameters.Handlers(Int)) -> Option(Int)("go""go": String, on_click = 33: Int) == SomeSome: fn(Int) -> Option(Int)(33: Int);
  expect ✓clickedclicked: fn(label: String, ..handlers: Book.Records.RestParameters.Handlers(String)) -> Option(String)("go""go": String, on_hover = "hover""hover": String) == NoneNone: Option(String);

Forwarding with spreads

..value among call arguments forwards the fields of a record value as labels. A sparse record forwards only the fields that are present, so a wrapper can pass its own rest record straight through.

let primary_buttonprimary_button: fn(label: String, ..attrs: Book.Records.RestParameters.ButtonAttrs) -> String = fn(labellabel: String: String, ..attrsattrs: Book.Records.RestParameters.ButtonAttrs: ButtonAttrs) -> String {
  buttonbutton: fn(label: String, ..attrs: Book.Records.RestParameters.ButtonAttrs) -> String(labellabel: String, class = "primary""primary": String, ..attrsattrs: Book.Records.RestParameters.ButtonAttrs)
};

Forward Rest

test✓
  let offoff: Book.Records.RestParameters.ButtonAttrs = ButtonAttrs { disabled = TrueTrue: Bool; };
  expect ✓buttonbutton: fn(label: String, ..attrs: Book.Records.RestParameters.ButtonAttrs) -> String("stop""stop": String, ..offoff: Book.Records.RestParameters.ButtonAttrs) == "stop/plain/off""stop/plain/off": String;
  expect ✓primary_buttonprimary_button: fn(label: String, ..attrs: Book.Records.RestParameters.ButtonAttrs) -> String("go""go": String, ..offoff: Book.Records.RestParameters.ButtonAttrs) == "go/primary/off""go/primary/off": String;
  expect ✓primary_buttonprimary_button: fn(label: String, ..attrs: Book.Records.RestParameters.ButtonAttrs) -> String("go""go": String, class = "danger""danger": String) == "go/danger/on""go/danger/on": String;

When a label and a spread field name the same target, the later one wins. An absent sparse field leaves the earlier source in place. A plain record forwards every field, so it always replaces what came before it.

type Theme = {
  class: String;
}

Later Wins

test✓
  let offoff: Book.Records.RestParameters.ButtonAttrs = ButtonAttrs { disabled = TrueTrue: Bool; };
  let themetheme: Book.Records.RestParameters.Theme = Theme { class = "themed""themed": String; };
  expect ✓buttonbutton: fn(label: String, ..attrs: Book.Records.RestParameters.ButtonAttrs) -> String("stop""stop": String, ..offoff: Book.Records.RestParameters.ButtonAttrs, disabled = FalseFalse: Bool) == "stop/plain/on""stop/plain/on": String;
  expect ✓buttonbutton: fn(label: String, ..attrs: Book.Records.RestParameters.ButtonAttrs) -> String("stop""stop": String, disabled = FalseFalse: Bool, ..offoff: Book.Records.RestParameters.ButtonAttrs) == "stop/plain/off""stop/plain/off": String;
  expect ✓buttonbutton: fn(label: String, ..attrs: Book.Records.RestParameters.ButtonAttrs) -> String("tint""tint": String, class = "lost""lost": String, ..themetheme: Book.Records.RestParameters.Theme) == "tint/themed/on""tint/themed/on": String;
  expect ✓buttonbutton: fn(label: String, ..attrs: Book.Records.RestParameters.ButtonAttrs) -> String("tint""tint": String, ..themetheme: Book.Records.RestParameters.Theme, class = "kept""kept": String) == "tint/kept/on""tint/kept/on": String;

Spreading into labeled parameters

A spread fills labeled parameters as well as rest fields. A field missing from a sparse spread falls back to an earlier source, and an Option label with no source is None as usual. A required or defaulted label needs a definite source before the spread.

type Options = sparse {
  verbose: Bool;
  level: Option(Int);
}

let configureconfigure: fn(name: String, ~verbose: Bool, ~level: Option(Int)) -> String = fn(namename: String: String, ~verboseverbose: Bool: Bool, ~levellevel: Option(Int): Option(Int)) -> String {
  let shownshown: String = match levellevel: Option(Int) {
    | Some(valuevalue: Int) -> Int.to_stringto_string: pure fn!(value: Int) -> String(valuevalue: Int)
    | None -> "default""default": String
  };
  let modemode: String = if verboseverbose: Bool {
    | "loud""loud": String
    | "quiet""quiet": String
  };
  namename: String ++ "/""/": String ++ modemode: String ++ "/""/": String ++ shownshown: String
};

Spread Into Labels

test✓
  let loudloud: Book.Records.RestParameters.Options = Options { verbose = TrueTrue: Bool; };
  let leveledleveled: Book.Records.RestParameters.Options = Options { level = Some(33: Int); };
  expect ✓configureconfigure: fn(name: String, ~verbose: Bool, ~level: Option(Int)) -> String("a""a": String, verbose = FalseFalse: Bool, ..loudloud: Book.Records.RestParameters.Options) == "a/loud/default""a/loud/default": String;
  expect ✓configureconfigure: fn(name: String, ~verbose: Bool, ~level: Option(Int)) -> String("b""b": String, ..loudloud: Book.Records.RestParameters.Options, verbose = FalseFalse: Bool) == "b/quiet/default""b/quiet/default": String;
  expect ✓configureconfigure: fn(name: String, ~verbose: Bool, ~level: Option(Int)) -> String("c""c": String, verbose = TrueTrue: Bool, ..leveledleveled: Book.Records.RestParameters.Options) == "c/loud/3""c/loud/3": String;

Every spread field must be a label of the call with exactly the same type. A record with fields the call does not accept is rejected; there is no silent filtering.

Function types and lambdas

A function type writes the rest record as ..Record, and a lambda takes a rest parameter like any function.

Function Types

test✓
  let renderrender: fn(label: String, ..attrs: Book.Records.RestParameters.ButtonAttrs) -> String = (labellabel: String: String, ..attrsattrs: Book.Records.RestParameters.ButtonAttrs: ButtonAttrs) =>
    buttonbutton: fn(label: String, ..attrs: Book.Records.RestParameters.ButtonAttrs) -> String(labellabel: String, ..attrsattrs: Book.Records.RestParameters.ButtonAttrs);
  let applyapply: fn(f: fn(String, ..Book.Records.RestParameters.ButtonAttrs) -> String) -> String = fn(ff: fn(String, ..Book.Records.RestParameters.ButtonAttrs) -> String: fn(String, ..ButtonAttrs) -> String) -> String {
    ff: fn(String, ..Book.Records.RestParameters.ButtonAttrs) -> String("ok""ok": String, class = "ghost""ghost": String)
  };
  expect ✓renderrender: fn(label: String, ..attrs: Book.Records.RestParameters.ButtonAttrs) -> String("hi""hi": String, disabled = TrueTrue: Bool) == "hi/plain/off""hi/plain/off": String;
  expect ✓applyapply: fn(f: fn(String, ..Book.Records.RestParameters.ButtonAttrs) -> String) -> String(renderrender: fn(label: String, ..attrs: Book.Records.RestParameters.ButtonAttrs) -> String) == "ok/ghost/on""ok/ghost/on": String;
  expect ✓applyapply: fn(f: fn(String, ..Book.Records.RestParameters.ButtonAttrs) -> String) -> String(buttonbutton: fn(label: String, ..attrs: Book.Records.RestParameters.ButtonAttrs) -> String) == "ok/ghost/on""ok/ghost/on": String;

Partial application

Partial application leaves the rest parameter open. Its fields are passed when the call completes. A spread needs the complete call, so ... and ..value do not mix.

Partial Rest

test✓
  let savesave: fn(..attrs: Book.Records.RestParameters.ButtonAttrs) -> String = buttonbutton: fn(label: String, ..attrs: Book.Records.RestParameters.ButtonAttrs) -> String("save""save": String, ...);
  expect ✓savesave: fn(..attrs: Book.Records.RestParameters.ButtonAttrs) -> String(class = "wide""wide": String) == "save/wide/on""save/wide/on": String;
  expect ✓savesave: fn(..attrs: Book.Records.RestParameters.ButtonAttrs) -> String() == "save/plain/on""save/plain/on": String;

Service operations

A service operation binds its arguments like any call: labels in any order, defaults, the rest record, and spreads.

effect service Labels {
  label: pure fn!(~first: String, ~second: String = "-", ..attrs: ButtonAttrs) -> String;
}

let service PlainLabelsPlainLabels: service Labels: Labels = {
  type t = Unit

  init = () => ()(): Unit;

  drop = (selfself: t: t) => ()(): Unit;

  label = fn(selfself: t: t, ~firstfirst: String: String, ~secondsecond: String: String = "-""-": String, ..attrsattrs: Book.Records.RestParameters.ButtonAttrs: ButtonAttrs) {
    firstfirst: String ++ secondsecond: String ++ "/""/": String ++ (attrsattrs: Book.Records.RestParameters.ButtonAttrs.classclass: Option(String) ?> "plain""plain": String)
  };
};

Service Operations

test✓
  let widewide: Book.Records.RestParameters.ButtonAttrs = ButtonAttrs { class = "wide""wide": String; };
  provide { Labels = PlainLabelsPlainLabels: service Labels; } {
    expect ✓Labels.labellabel: fn!(~first: String, ~second: String = "-", ..attrs: Book.Records.RestParameters.ButtonAttrs) -> String, eff Labels(second = "b""b": String, first = "a""a": String) == "ab/plain""ab/plain": String;
    expect ✓Labels.labellabel: fn!(~first: String, ~second: String = "-", ..attrs: Book.Records.RestParameters.ButtonAttrs) -> String, eff Labels(first = "c""c": String, ..widewide: Book.Records.RestParameters.ButtonAttrs) == "c-/wide""c-/wide": String;
  };

Evaluation order

Without spreads a call evaluates its arguments in parameter order, and the rest record's labels in field declaration order. A call with a spread evaluates its written arguments left to right, spreads included, before the callee runs.

Evaluation Order

test✓
  let tracetrace: Book.Records.RestParameters.Visits = Visits { digits = 00: Int; };
  let collectedcollected: String = buttonbutton: fn(label: String, ..attrs: Book.Records.RestParameters.ButtonAttrs) -> String(
    markmark: fn(trace: Book.Records.RestParameters.Visits, digit: Int, value: String) -> String(tracetrace: Book.Records.RestParameters.Visits, 11: Int, "save""save": String),
    disabled = markmark: fn(trace: Book.Records.RestParameters.Visits, digit: Int, value: Bool) -> Bool(tracetrace: Book.Records.RestParameters.Visits, 33: Int, TrueTrue: Bool),
    class = markmark: fn(trace: Book.Records.RestParameters.Visits, digit: Int, value: String) -> String(tracetrace: Book.Records.RestParameters.Visits, 22: Int, "wide""wide": String),
  );
  expect ✓(tracetrace: Book.Records.RestParameters.Visits.digitsdigits: Int, collectedcollected: String) == (123123: Int, "save/wide/off""save/wide/off": String);
  trace.digits := 00: Int;

  let themetheme: Book.Records.RestParameters.Theme = Theme { class = "themed""themed": String; };
  let spreadspread: String = buttonbutton: fn(label: String, ..attrs: Book.Records.RestParameters.ButtonAttrs) -> String(
    markmark: fn(trace: Book.Records.RestParameters.Visits, digit: Int, value: String) -> String(tracetrace: Book.Records.RestParameters.Visits, 11: Int, "tint""tint": String),
    disabled = markmark: fn(trace: Book.Records.RestParameters.Visits, digit: Int, value: Bool) -> Bool(tracetrace: Book.Records.RestParameters.Visits, 22: Int, FalseFalse: Bool),
    ..markmark: fn(trace: Book.Records.RestParameters.Visits, digit: Int, value: Book.Records.RestParameters.Theme) -> Book.Records.RestParameters.Theme(tracetrace: Book.Records.RestParameters.Visits, 33: Int, themetheme: Book.Records.RestParameters.Theme),
  );
  expect ✓(tracetrace: Book.Records.RestParameters.Visits.digitsdigits: Int, spreadspread: String) == (123123: Int, "tint/themed/on""tint/themed/on": String);

Browser builds bind rest parameters and spreads the same way.

crates/ship-cli/tests/golden/browser-rest-spreads/run.t

Rest parameters and call-site spreads lower to ordinary calls in browser programs: labels collect into the sparse rest record, spreads forward present fields, later sources win, absent sparse fields fall back, and calls with spreads evaluate their written arguments left to right. Service operations bind their arguments the same way, and an element wrapper spreads its rest record into a typed Html element.

✓
$mkdir -p build/test && rm -f build/test/browser.txt && BROWSER_OUTPUT="$PWD/build/test/browser.txt" ship test --browser --spec browser-output.spec.js . >/dev/null && cat build/test/browser.txt
collect:save/wide/off
empty:save/plain/on
forward:go/primary/off
override:go/danger/on
later:stop/plain/on
plain:tint/themed/on
labels:a/loud/default
fallback:b/quiet/default
option:c/loud/3
order:order/themed/on/132
service:ab/plain c-/wide
wrapped:Wrapped/700/2

Unknown labels, repeated labels, extra or mistyped spread fields, and misplaced rest parameters are errors. Rejected programs lists them with their diagnostics.