Ship

Book · Sparse records

Sparse records

Draft

Represent sparse values whose fields may be omitted.

On this page · 3 sections

Omitted fields

sparse makes every field omittable during construction. A read sees whether the field was supplied, so each stored field is wrapped in Option.

type UserUpdate = sparse {
  name: String;
  display_name: Option(String);
  avatar_url: String;
}

A field that is already an Option gains a second layer. The outer layer distinguishes an omitted update from an update that explicitly clears the value.

let describe_displaydescribe_display: fn(Book.Records.SparseRecords.UserUpdate) -> String = function {
  | UserUpdate { display_namedisplay_name: Option(Option(String)); ..; } ->
    match display_namedisplay_name: Option(Option(String)) {
      | None -> "omitted""omitted": String
      | Some(None) -> "cleared""cleared": String
      | Some(Some(valuevalue: String)) -> "set:""set:": String ++ valuevalue: String
    }
};

Sparse Records

test✓
  let emptyempty: Book.Records.SparseRecords.UserUpdate = UserUpdate {};
  ✓emptyempty: Book.Records.SparseRecords.UserUpdate.namename: Option(String): Option(String);
  ✓emptyempty: Book.Records.SparseRecords.UserUpdate.display_namedisplay_name: Option(Option(String)): Option(Option(String));
  expect ✓emptyempty: Book.Records.SparseRecords.UserUpdate.namename: Option(String) == NoneNone: Option(String);

  let renamerename: Book.Records.SparseRecords.UserUpdate = UserUpdate { name = "Ada""Ada": String; };
  expect ✓renamerename: Book.Records.SparseRecords.UserUpdate.namename: Option(String) == SomeSome: fn(String) -> Option(String)("Ada""Ada": String);

  let clear_displayclear_display: Book.Records.SparseRecords.UserUpdate = UserUpdate { display_name = None; };
  let set_displayset_display: Book.Records.SparseRecords.UserUpdate = UserUpdate { display_name = Some("Countess""Countess": String); };
  ✓describe_displaydescribe_display: fn(Book.Records.SparseRecords.UserUpdate) -> String: fn(UserUpdate) -> String;
  expect ✓describe_displaydescribe_display: fn(Book.Records.SparseRecords.UserUpdate) -> String(emptyempty: Book.Records.SparseRecords.UserUpdate) == "omitted""omitted": String;
  expect ✓describe_displaydescribe_display: fn(Book.Records.SparseRecords.UserUpdate) -> String(clear_displayclear_display: Book.Records.SparseRecords.UserUpdate) == "cleared""cleared": String;
  expect ✓describe_displaydescribe_display: fn(Book.Records.SparseRecords.UserUpdate) -> String(set_displayset_display: Book.Records.SparseRecords.UserUpdate) == "set:Countess""set:Countess": String;

  let with_avatarwith_avatar: Book.Records.SparseRecords.UserUpdate = { renamerename: Book.Records.SparseRecords.UserUpdate with avatar_url = "ada.png""ada.png": String; };
  expect ✓with_avatarwith_avatar: Book.Records.SparseRecords.UserUpdate.avatar_urlavatar_url: Option(String) == SomeSome: fn(String) -> Option(String)("ada.png""ada.png": String);

Unknown field names and wrong field types are rejected, the same as on an ordinary record.

Sparse encoding

Default JSON encoding follows the sparse constructor shape: fields stored as None are omitted. Decode reads that same shape: missing keys stay omitted.

Sparse Encoding

test✓
  let empty_jsonempty_json: String = must String.of_bytesof_bytes: pure fn(bytes: Bytes) -> String, exn [ :utf8(Int) ](Json.encodeencode: pure fn('a: Encode)(value: Book.Records.SparseRecords.UserUpdate) -> Bytes, exn [ :encode(Wire.EncodeError) ](UserUpdate {}));
  expect ✓empty_jsonempty_json: String == "{}""{}": String;
  expect ✓must Json.decodedecode: pure fn('target: Decode)(body: Bytes, target: Struct(Book.Records.SparseRecords.UserUpdate), ~max_depth: Int = 128) -> Book.Records.SparseRecords.UserUpdate, exn [ :decode(Wire.DecodeError) ](Bytes.of_stringof_string: pure fn!(string: String) -> Bytes(empty_jsonempty_json: String), UserUpdate) == UserUpdate {};

  let rename_jsonrename_json: String = must String.of_bytesof_bytes: pure fn(bytes: Bytes) -> String, exn [ :utf8(Int) ](Json.encodeencode: pure fn('a: Encode)(value: Book.Records.SparseRecords.UserUpdate) -> Bytes, exn [ :encode(Wire.EncodeError) ](UserUpdate { name = "Ada""Ada": String; }));
  expect ✓rename_jsonrename_json: String == "{\"name\":\"Ada\"}""{\"name\":\"Ada\"}": String;
  expect ✓must Json.decodedecode: pure fn('target: Decode)(body: Bytes, target: Struct(Book.Records.SparseRecords.UserUpdate), ~max_depth: Int = 128) -> Book.Records.SparseRecords.UserUpdate, exn [ :decode(Wire.DecodeError) ](Bytes.of_stringof_string: pure fn!(string: String) -> Bytes(rename_jsonrename_json: String), UserUpdate) == UserUpdate { name = "Ada""Ada": String; };

A JSON null on an already-optional field is a clear: the field was supplied, and the stored value is None.

Sparse tag payloads

A tag in an open variant can carry a sparse record payload. Construction may omit any field, and a bare tag is the payload with every field omitted: :checkbox and :checkbox {} are the same value.

type InputKind = [
  | :checkbox sparse { checked: Bool; label: String }
  | :number sparse { value: Int; min: Int; max: Int }
]

Reads see each field as an Option, as on a sparse record. A bare tag pattern matches its tag whichever fields were supplied.

let describe_inputdescribe_input: fn(input: Book.Records.SparseRecords.InputKind) -> String = fn(inputinput: Book.Records.SparseRecords.InputKind: InputKind) -> String {
  match inputinput: Book.Records.SparseRecords.InputKind {
    | :checkbox { checked = Some(True); ..; } -> "checked""checked": String
    | :checkbox -> "checkbox""checkbox": String
    | :number { min = Some(_); max = Some(_); ..; } -> "bounded""bounded": String
    | :number -> "number""number": String
  }
};

Sparse Tag Payloads

test✓
  let checkedchecked: Book.Records.SparseRecords.InputKind: InputKind = :checkbox { checked = TrueTrue: Bool; };
  let barebare: Book.Records.SparseRecords.InputKind: InputKind = :checkbox;
  let emptyempty: Book.Records.SparseRecords.InputKind: InputKind = :checkbox {};
  expect ✓barebare: Book.Records.SparseRecords.InputKind == emptyempty: Book.Records.SparseRecords.InputKind;
  expect ✓describe_inputdescribe_input: fn(input: Book.Records.SparseRecords.InputKind) -> String(checkedchecked: Book.Records.SparseRecords.InputKind) == "checked""checked": String;
  expect ✓describe_inputdescribe_input: fn(input: Book.Records.SparseRecords.InputKind) -> String(barebare: Book.Records.SparseRecords.InputKind) == "checkbox""checkbox": String;
  expect ✓describe_inputdescribe_input: fn(input: Book.Records.SparseRecords.InputKind) -> String(:number { min = 00: Int; max = 1010: Int; }) == "bounded""bounded": String;
  expect ✓describe_inputdescribe_input: fn(input: Book.Records.SparseRecords.InputKind) -> String(:number { value = 55: Int; }) == "number""number": String;

JSON encoding omits omitted fields, the same as a sparse record.

Sparse Tag Encoding

test✓
  let barebare: Book.Records.SparseRecords.InputKind: InputKind = :checkbox;
  expect ✓must String.of_bytesof_bytes: pure fn(bytes: Bytes) -> String, exn [ :utf8(Int) ](Json.encodeencode: pure fn('a: Encode)(value: Book.Records.SparseRecords.InputKind) -> Bytes, exn [ :encode(Wire.EncodeError) ](barebare: Book.Records.SparseRecords.InputKind)) == "{\":checkbox\":{}}""{\":checkbox\":{}}": String;

  let checkedchecked: Book.Records.SparseRecords.InputKind: InputKind = :checkbox { checked = TrueTrue: Bool; };
  expect ✓must String.of_bytesof_bytes: pure fn(bytes: Bytes) -> String, exn [ :utf8(Int) ](Json.encodeencode: pure fn('a: Encode)(value: Book.Records.SparseRecords.InputKind) -> Bytes, exn [ :encode(Wire.EncodeError) ](checkedchecked: Book.Records.SparseRecords.InputKind)) == "{\":checkbox\":{\"checked\":true}}""{\":checkbox\":{\"checked\":true}}": String;

Display and inspection show only the supplied fields.

A payload field that is already an Option gains a second layer, so an omitted field and a cleared one stay distinct.

Aliases that include the tag keep its payload sparse.

type Toggle('msg) = [
  | :toggle sparse { on: Bool; changed: pure fn(Bool) -> 'msg }
]

type Control('msg) = [
  | Toggle('msg)
  | :button(String)
]

Sparse Tag Composition

test✓
  let onon: fn(control: Book.Records.SparseRecords.Control(String)) -> Option(Bool) = fn(controlcontrol: Book.Records.SparseRecords.Control(String): Control(String)) -> Option(Bool) {
    match controlcontrol: Book.Records.SparseRecords.Control(String) {
      | :toggle { onon: Option(Bool); ..; } -> onon: Option(Bool)
      | :button(_) -> None
    }
  };
  let toggletoggle: Book.Records.SparseRecords.Control(String): Control(String) = :toggle { on = TrueTrue: Bool; };
  let barebare: Book.Records.SparseRecords.Control(String): Control(String) = :toggle;
  expect ✓onon: fn(control: Book.Records.SparseRecords.Control(String)) -> Option(Bool)(toggletoggle: Book.Records.SparseRecords.Control(String)) == SomeSome: fn(Bool) -> Option(Bool)(TrueTrue: Bool);
  expect ✓onon: fn(control: Book.Records.SparseRecords.Control(String)) -> Option(Bool)(barebare: Book.Records.SparseRecords.Control(String)) == NoneNone: Option(Bool);