Ship

Book · Tag records

Tag records

Draft

Hold at most one payload for each tag of an open variant.

On this page · 7 sections

One field per tag

Tags(V) turns an exact open-variant row into a record with one optional field per tag. Each field is named by its tag and holds that tag's payload. Rows compose the same way variants do, so tags declared separately can be gathered into one record.

type State = [
  | :open
  | :closed
]

type CardData = [
  | :card { state: State }
]

type ColorData = [
  | :color { highlight: String }
]

type Hooks = Tags([ CardData | ColorData | :pinned | :order(Int) ])

A tag record literal lists tags, each followed by its payload. A tag that is not written is an omitted field, and {} is the empty tag record. The leading : of each entry tells a tag record apart from a record or a block.

let hookshooks: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ]): Hooks = {
  :card { state = :open; };
  :color { highlight = "green""green": String; };
};

let pinnedpinned: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ]): Hooks = { :pinned; :order(22: Int); };
let emptyempty: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ]): Hooks = {};

Reading fields

Every field is an Option: Some(payload) when the tag was written and None when it was omitted. A tag without a payload stores ().

Reading Fields

test✓
  ✓pinnedpinned: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ]).orderorder: Option(Int): Option(Int);
  ✓pinnedpinned: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ]).pinnedpinned: Option(Unit): Option(Unit);
  expect ✓pinnedpinned: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ]).orderorder: Option(Int) == SomeSome: fn(Int) -> Option(Int)(22: Int);
  expect ✓pinnedpinned: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ]).pinnedpinned: Option(Unit) == SomeSome: fn(Unit) -> Option(Unit)(());
  expect ✓hookshooks: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ]).pinnedpinned: Option(Unit) == NoneNone: Option(Unit);
  expect ✓emptyempty: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ]).orderorder: Option(Int) == NoneNone: Option(Int);

  let highlighthighlight: String = match hookshooks: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ]).colorcolor: Option(named fields (highlight: String)) {
    | Some(colorcolor: named fields (highlight: String)) -> colorcolor: named fields (highlight: String).highlighthighlight: String
    | None -> "none""none": String
  };
  expect ✓highlighthighlight: String == "green""green": String;

Matching

Record patterns name fields, as on any record.

let describedescribe: fn(hooks: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ])) -> String = fn(hookshooks: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ]): Hooks) -> String {
  match hookshooks: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ]) {
    | { card = Some(cardcard: named fields (state: Book.Records.TagRecords.State)); ..; } ->
      match cardcard: named fields (state: Book.Records.TagRecords.State).statestate: Book.Records.TagRecords.State {
        | :open -> "open card""open card": String
        | :closed -> "closed card""closed card": String
      }
    | { pinned = Some(_); ..; } -> "pinned""pinned": String
    | _ -> "plain""plain": String
  }
};

Matching

test✓
  expect ✓describedescribe: fn(hooks: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ])) -> String(hookshooks: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ])) == "open card""open card": String;
  expect ✓describedescribe: fn(hooks: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ])) -> String(pinnedpinned: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ])) == "pinned""pinned": String;
  expect ✓describedescribe: fn(hooks: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ])) -> String(emptyempty: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ])) == "plain""plain": String;

Updating

with takes tag entries. Each one adds its tag or replaces the payload already stored, and every other field is kept.

Updating

test✓
  let redred: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ]) = { hookshooks: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ]) with :color { highlight = "red""red": String; }; :pinned; };
  expect ✓redred: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ]) == {
    :card { state = :open; };
    :color { highlight = "red""red": String; };
    :pinned;
  };
  expect ✓hookshooks: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ]).colorcolor: Option(named fields (highlight: String)) != redred: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ]).colorcolor: Option(named fields (highlight: String));

  let copiedcopied: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ]) = match hookshooks: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ]).cardcard: Option(named fields (state: Book.Records.TagRecords.State)) {
    | Some(cardcard: named fields (state: Book.Records.TagRecords.State)) -> { pinnedpinned: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ]) with :card cardcard: named fields (state: Book.Records.TagRecords.State); }
    | None -> pinnedpinned: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ])
  };
  expect ✓copiedcopied: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ]).cardcard: Option(named fields (state: Book.Records.TagRecords.State)) == hookshooks: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ]).cardcard: Option(named fields (state: Book.Records.TagRecords.State));

Sparse payloads

A tag with a sparse payload is written the same way in a tag record as on its own: any field may be omitted, and a bare tag omits every field.

type FieldData = [
  | :field sparse { label: String; required: Bool }
]

type NoteData = [
  | :note sparse { text: String }
]

type Form = Tags([ FieldData | NoteData ])

let formform: Tags([ :field sparse { label: String; required: Bool } | :note sparse { text: String } ]): Form = { :field { label = "Email""Email": String; }; :note; };

Reading the tag gives an Option of its payload, and each payload field is an Option too. Display shows only the fields that were written.

Sparse Payloads

test✓
  let fieldfield: named fields (label: Option(String); required: Option(Bool)) = match formform: Tags([ :field sparse { label: String; required: Bool } | :note sparse { text: String } ]).fieldfield: Option(named fields (label: Option(String); required: Option(Bool))) {
    | Some(fieldfield: named fields (label: Option(String); required: Option(Bool))) -> fieldfield: named fields (label: Option(String); required: Option(Bool))
    | None -> { label = None; required = None; }
  };
  expect ✓fieldfield: named fields (label: Option(String); required: Option(Bool)).labellabel: Option(String) == SomeSome: fn(String) -> Option(String)("Email""Email": String);
  expect ✓fieldfield: named fields (label: Option(String); required: Option(Bool)).requiredrequired: Option(Bool) == NoneNone: Option(Bool);

  let texttext: Option(String) = match formform: Tags([ :field sparse { label: String; required: Bool } | :note sparse { text: String } ]).notenote: Option(named fields (text: Option(String))) {
    | Some(notenote: named fields (text: Option(String))) -> notenote: named fields (text: Option(String)).texttext: Option(String)
    | None -> Some("no note""no note": String)
  };
  expect ✓texttext: Option(String) == NoneNone: Option(String);
  expect ✓formform: Tags([ :field sparse { label: String; required: Bool } | :note sparse { text: String } ]) == { :note {}; :field { label = "Email""Email": String; }; };

  let filledfilled: Tags([ :field sparse { label: String; required: Bool } | :note sparse { text: String } ]) = { formform: Tags([ :field sparse { label: String; required: Bool } | :note sparse { text: String } ]) with :note { text = "Optional""Optional": String; }; :field; };
  expect ✓Display.displaydisplay: pure fn!('a: Display)(value: Tags([ :field sparse { label: String; required: Bool } | :note sparse { text: String } ])) -> String(filledfilled: Tags([ :field sparse { label: String; required: Bool } | :note sparse { text: String } ])) == "{field = {}; note = {text = Optional}}""{field = {}; note = {text = Optional}}": String;

Payload fields may be written in any order.

One payload per tag

A tag record holds at most one payload for each tag. Writing a tag twice is the usual duplicate-field error. A tag outside the row is rejected, and the error lists the tags the row allows. Payloads are checked against their tag's payload type, and a sparse payload rejects unknown, mistyped, and repeated fields as it does outside a tag record.

crates/ship-types/tests/golden/check-tag-records/run.t

A non-variant argument has no tags.

✓
type Hooks = Tags(Int)
$ship-types-test non-variant.shp
error: `Tags` expects an open-variant row
  --> non-variant.shp:1:19
    |
  1 | type Hooks = Tags(Int)
    |                   ^^^ found `Int`
help: write `Tags([ :tag | .. ])`

Bounded rows have no fixed set of tags.

✓
type Lower = Tags([ > :card | :pinned ])
type Upper = Tags([ < :card | :pinned ])
$ship-types-test bounded.shp
error: `Tags` needs an exact open-variant row
  --> bounded.shp:1:19
    |
  1 | type Lower = Tags([ > :card | :pinned ])
    |                   ^^^^^^^^^^^^^^^^^^^^^ bounded or row-polymorphic variants have no fixed set of tags

error: `Tags` needs an exact open-variant row
  --> bounded.shp:2:19
    |
  2 | type Upper = Tags([ < :card | :pinned ])
    |                   ^^^^^^^^^^^^^^^^^^^^^ bounded or row-polymorphic variants have no fixed set of tags

A literal entry must name a tag of the row.

✓
type Hooks = Tags([ :card { state: Int } | :pinned ])

let hooks: Hooks = { :pinned; :color("red"); };
$ship-types-test unknown-tag.shp
error: unknown tag `:color` in tag record
  --> unknown-tag.shp:3:31
    |
  3 | let hooks: Hooks = { :pinned; :color("red"); };
    |                               ^^^^^^ allowed tags: `:card`, `:pinned`

A tag written twice is a duplicate field.

✓
type Hooks = Tags([ :card { state: Int } | :pinned ])

let hooks: Hooks = { :pinned; :card { state = 1; }; :pinned; };
$ship-types-test duplicate-tag.shp
error: duplicate field `pinned`
  --> duplicate-tag.shp:3:53
    |
  3 | let hooks: Hooks = { :pinned; :card { state = 1; }; :pinned; };
    |                                                     ^^^^^^^ field is specified more than once

A payload is checked against the tag's payload type.

✓
type Hooks = Tags([ :card { state: Int } | :pinned ])

let hooks: Hooks = { :card { state = "open"; }; };
$ship-types-test wrong-payload.shp
error: type mismatch
  --> wrong-payload.shp:3:38
    |
  3 | let hooks: Hooks = { :card { state = "open"; }; };
    |                                      ^^^^^^ expected Int, found String

A sparse payload follows its tag's sparse construction rules, in literals and updates alike.

✓
type Hooks = Tags([ :card sparse { state: Int; label: String } | :pinned ])

let unknown: Hooks = { :card { stat = 1; }; };
let mistyped: Hooks = { :card { state = "open"; }; };
let duplicate: Hooks = { :card { state = 1; state = 2; }; };
let updated = { unknown with :card { label = 3; }; };
$ship-types-test sparse-payload.shp
error: unknown field `stat`
  --> sparse-payload.shp:3:30
    |
  3 | let unknown: Hooks = { :card { stat = 1; }; };
    |                              ^^^^^^^^^^^^^ not a field on tag payload `:card`

error: type mismatch
  --> sparse-payload.shp:4:41
    |
  4 | let mistyped: Hooks = { :card { state = "open"; }; };
    |                                         ^^^^^^ expected Int, found String

error: duplicate field `state`
  --> sparse-payload.shp:5:32
    |
  5 | let duplicate: Hooks = { :card { state = 1; state = 2; }; };
    |                                ^^^^^^^^^^^^^^^^^^^^^^^^^ field is specified more than once

error: type mismatch
  --> sparse-payload.shp:6:46
    |
  6 | let updated = { unknown with :card { label = 3; }; };
    |                                              ^ expected String, found Int

Structural identity

A tag record is identified by its row, so two Tags(...) over the same tags are the same type, whichever aliases spell them. Display and inspection print the written fields and leave out omitted ones.

type SameHooks = Tags([ :order(Int) | :pinned | ColorData | CardData ])

Structural Identity

test✓
  let samesame: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ]): SameHooks = pinnedpinned: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ]);
  expect ✓samesame: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ]) == pinnedpinned: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ]);
  expect ✓Inspect.inspectinspect: pure fn!('a: Inspect)(value: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ])) -> String(pinnedpinned: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ])) == "{order = 2; pinned = ()}""{order = 2; pinned = ()}": String;
  expect ✓Display.displaydisplay: pure fn!('a: Display)(value: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ])) -> String(hookshooks: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ])) == "{card = {state = :open}; color = {highlight = green}}""{card = {state = :open}; color = {highlight = green}}": String;
  expect ✓Inspect.inspectinspect: pure fn!('a: Inspect)(value: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ])) -> String(emptyempty: Tags([ :card { state: [ :closed | :open ] } | :color { highlight: String } | :order(Int) | :pinned ])) == "{}""{}": String;

Without an expected type, a literal has exactly the tags it writes.