Ship

Book · Pattern matching

Pattern matching

Draft

Select among domain shapes with exhaustive, readable branches.

On this page · 11 sections

Closed variants

Closed variants are exhaustive at compile time. Arms are | pat -> expr.

type! Maybe('a) =
  | Present('a)
  | Absent

Payloads

Constructor payloads unpack in the arm. Nested tuples unpack there too.

let describedescribe: fn(Book.PatternMatching.Maybe((Int, String))) -> String = function {
  | Present((7, labellabel: String)) -> labellabel: String
  | Present((_count_count: Int, _label_label: String)) -> "other""other": String
  | Absent -> "none""none": String
};

Tuple Payloads

test✓
  expect ✓describedescribe: fn(Book.PatternMatching.Maybe((Int, String))) -> String(Present((77: Int, "count=""count=": String))) == "count=""count=": String;
  expect ✓describedescribe: fn(Book.PatternMatching.Maybe((Int, String))) -> String(Absent) == "none""none": String;

Named payloads unpack stored fields without a separate wrapper type.

type BillingEvent =
  | TrialStarted
  | PaymentFailed {
    invoice_id: String;
    reason: String;
  }

let describe_billingdescribe_billing: fn(Book.PatternMatching.BillingEvent) -> String = function {
  | TrialStarted -> "trial""trial": String
  | PaymentFailed { invoice_idinvoice_id: String; reasonreason: String; } -> invoice_idinvoice_id: String ++ ":"":": String ++ reasonreason: String
};

Named Payloads

test✓
  let failedfailed: Book.PatternMatching.BillingEvent = PaymentFailedPaymentFailed: fn(sparse named fields (invoice_id: String; reason: String)) -> Book.PatternMatching.BillingEvent { invoice_id = "inv-7""inv-7": String; reason = "declined""declined": String; };
  expect ✓describe_billingdescribe_billing: fn(Book.PatternMatching.BillingEvent) -> String(failedfailed: Book.PatternMatching.BillingEvent) == "inv-7:declined""inv-7:declined": String;

Exhaustiveness

Matching every constructor is required. There is no leftover case.

type Status =
  | Open
  | Done

let statusstatus: fn(Book.PatternMatching.Status) -> String = function {
  | Open -> "open""open": String
  | Done -> "done""done": String
};

Exhaustive Variants

test✓
  expect ✓statusstatus: fn(Book.PatternMatching.Status) -> String(Open) == "open""open": String;
  expect ✓statusstatus: fn(Book.PatternMatching.Status) -> String(Done) == "done""done": String;

Structural tags

An exact open-variant row is structural and still finite, so every tag must be matched.

type Readiness = [
  | :ready
  | :waiting
]

let readinessreadiness: fn(value: Book.PatternMatching.Readiness) -> String = (valuevalue: Book.PatternMatching.Readiness: Readiness) =>
  match valuevalue: Book.PatternMatching.Readiness {
    | :ready -> "ready""ready": String
    | :waiting -> "waiting""waiting": String
  };

Exact Tag Rows

test✓
  expect ✓readinessreadiness: fn(value: Book.PatternMatching.Readiness) -> String(:ready) == "ready""ready": String;
  expect ✓readinessreadiness: fn(value: Book.PatternMatching.Readiness) -> String(:waiting) == "waiting""waiting": String;

as, or-patterns, and when

as binds the whole matched value.

let describe_asdescribe_as: fn(Book.PatternMatching.Maybe(String)) -> String = function {
  | Present(texttext: String) as wholewhole: Book.PatternMatching.Maybe(String) -> texttext: String
  | Absent -> "none""none": String
};

Or-patterns share one body.

type Choice =
  | Left(String)
  | Right(String)

let describe_ordescribe_or: fn(Book.PatternMatching.Choice) -> String = function {
  | Left(texttext: String) | Right(texttext: String) -> texttext: String
};

when refines an arm with an ordinary Bool expression evaluated after the pattern matches. False falls through. Calls may add errors and effects to the surrounding function; an error propagates rather than counting as a failed guard, and effects remain observable even when the guard returns False.

let describe_guarddescribe_guard: fn(value: Book.PatternMatching.Maybe(String), flag: Bool) -> String = (valuevalue: Book.PatternMatching.Maybe(String), flagflag: Bool) =>
  match valuevalue: Book.PatternMatching.Maybe(String) {
    | Present(texttext: String) when flagflag: Bool -> texttext: String
    | _ -> "other""other": String
  };

As, Or, And When

test✓
  expect ✓describe_asdescribe_as: fn(Book.PatternMatching.Maybe(String)) -> String(Present("as""as": String)) == "as""as": String;
  expect ✓describe_ordescribe_or: fn(Book.PatternMatching.Choice) -> String(Right("or""or": String)) == "or""or": String;
  expect ✓describe_guarddescribe_guard: fn(value: Book.PatternMatching.Maybe(String), flag: Bool) -> String(Present("guard""guard": String), TrueTrue: Bool) == "guard""guard": String;
  expect ✓describe_guarddescribe_guard: fn(value: Book.PatternMatching.Maybe(String), flag: Bool) -> String(Present("guard""guard": String), FalseFalse: Bool) == "other""other": String, "a failing guard takes the catch-all""a failing guard takes the catch-all": String;

Partial patterns

pattern names a reusable partial recognizer. Its input and extracted value infer from the body; Some matches and None falls through.

pattern IntSegmentIntSegment: pure fn!(text: String) -> Option(Int)(texttext: String) { Int.parse_optionparse_option: pure fn!(raw: String) -> Option(Int)(texttext: String) }

let describe_segmentdescribe_segment: fn(String) -> String = function {
  | IntSegment(idid: Int) -> "integer:""integer:": String ++ Int.to_stringto_string: pure fn!(value: Int) -> String(idid: Int)
  | _ -> "other""other": String
};

Partial Patterns

test✓
  ✓describe_segmentdescribe_segment: fn(String) -> String: pure fn!(String) -> String;
  expect ✓describe_segmentdescribe_segment: fn(String) -> String("42""42": String) == "integer:42""integer:42": String;
  expect ✓describe_segmentdescribe_segment: fn(String) -> String("forty-two""forty-two": String) == "other""other": String;

Bools, lists, and products

Bool is an ordinary closed variant.

let describe_booldescribe_bool: fn(Bool) -> String = function {
  | True -> "True""True": String
  | False -> "False""False": String
};

Lists match empty, head/tail, and prefixed shapes.

let describe_listdescribe_list: fn(values: List(String)) -> String = (valuesvalues: List(String): List(String)) =>
  match valuesvalues: List(String) {
    | [] -> "empty""empty": String
    | [headhead: String, ..tailtail: List(String)] -> headhead: String
  };

let describe_prefixeddescribe_prefixed: fn(values: List(String)) -> String = (valuesvalues: List(String): List(String)) =>
  match valuesvalues: List(String) {
    | ["prefix", .._] -> "prefixed""prefixed": String
    | _ -> "other""other": String
  };

Product rest { x; ..; } keeps unread fields.

type Point = {
  x: Int;
  y: Int;
}

let sum_pointsum_point: fn(Book.PatternMatching.Point) -> Int = function {
  | Point { xx: Int; ..; } -> xx: Int
};

Bools, Lists, And Rest

test✓
  expect ✓describe_booldescribe_bool: fn(Bool) -> String(TrueTrue: Bool) == "True""True": String;
  expect ✓describe_booldescribe_bool: fn(Bool) -> String(FalseFalse: Bool) == "False""False": String;
  expect ✓describe_listdescribe_list: fn(values: List(String)) -> String(listlist: pure fn!('a)(...String) -> List(String)("head""head": String, "tail""tail": String)) == "head""head": String;
  expect ✓describe_prefixeddescribe_prefixed: fn(values: List(String)) -> String(listlist: pure fn!('a)(...String) -> List(String)("prefix""prefix": String, "tail""tail": String)) == "prefixed""prefixed": String;
  expect ✓describe_prefixeddescribe_prefixed: fn(values: List(String)) -> String(listlist: pure fn!('a)(...String) -> List(String)("other""other": String, "tail""tail": String)) == "other""other": String;

  let pointpoint: Book.PatternMatching.Point = Point { x = 33: Int; y = 44: Int; };
  expect ✓sum_pointsum_point: fn(Book.PatternMatching.Point) -> Int(pointpoint: Book.PatternMatching.Point) == 33: Int;

The type name is optional when the scrutinee's type is known. A bare record pattern tests and binds the product's fields at any depth.

type Segment = {
  start: Point;
  finish: Point;
}

let describe_segment_pointsdescribe_segment_points: fn(segment: Book.PatternMatching.Segment) -> String = (segmentsegment: Book.PatternMatching.Segment: Segment) =>
  match segmentsegment: Book.PatternMatching.Segment {
    | { start = { x = 0; y = 0; }; ..; } -> "from origin""from origin": String
    | { finish = { xx: Int; ..; }; ..; } -> "to x=""to x=": String ++ Int.to_stringto_string: pure fn!(value: Int) -> String(xx: Int)
  };

Bare Record Patterns

test✓
  let originorigin: Book.PatternMatching.Point = Point { x = 00: Int; y = 00: Int; };
  let pointpoint: Book.PatternMatching.Point = Point { x = 33: Int; y = 44: Int; };
  let outwardoutward: Book.PatternMatching.Segment = Segment { start = originorigin: Book.PatternMatching.Point; finish = pointpoint: Book.PatternMatching.Point; };
  let inwardinward: Book.PatternMatching.Segment = Segment { start = pointpoint: Book.PatternMatching.Point; finish = originorigin: Book.PatternMatching.Point; };
  expect ✓describe_segment_pointsdescribe_segment_points: fn(segment: Book.PatternMatching.Segment) -> String(outwardoutward: Book.PatternMatching.Segment) == "from origin""from origin": String;
  expect ✓describe_segment_pointsdescribe_segment_points: fn(segment: Book.PatternMatching.Segment) -> String(inwardinward: Book.PatternMatching.Segment) == "to x=0""to x=0": String;

  let { xx: Int; yy: Int; } = pointpoint: Book.PatternMatching.Point;
  expect ✓xx: Int + yy: Int == 77: Int;

  let first_xfirst_x: Int = match SomeSome: fn(Book.PatternMatching.Point) -> Option(Book.PatternMatching.Point)(pointpoint: Book.PatternMatching.Point) {
    | Some({ xx: Int; ..; }) -> xx: Int
    | None -> 00: Int
  };
  expect ✓first_xfirst_x: Int == 33: Int;

Constructor resolution

An expected type selects an unqualified constructor. Patterns may qualify the declaring module when neighboring domains reuse the same name. Rejected programs shows the ambiguous and unknown names that fail.

module struct First {
  type t = {
    marker: Int;
  }

  type Value =
    | Shared(Int)
    | OnlyFirst
}

module struct Second {
  type t = {
    marker: Int;
  }

  type Value =
    | Shared(Int)
    | OnlySecond
}

let describe_firstdescribe_first: fn(Book.PatternMatching.First.Value) -> String = function {
  | First.Shared(nn: Int) -> "first:""first:": String ++ Int.to_stringto_string: pure fn!(value: Int) -> String(nn: Int)
  | First.OnlyFirst -> "only-first""only-first": String
};

let describe_seconddescribe_second: fn(Book.PatternMatching.Second.Value) -> String = function {
  | Second.Shared(nn: Int) -> "second:""second:": String ++ Int.to_stringto_string: pure fn!(value: Int) -> String(nn: Int)
  | Second.OnlySecond -> "only-second""only-second": String
};

Constructor Resolution

test✓
  let firstfirst: Book.PatternMatching.First.Value: First.Value = Shared(44: Int);
  let secondsecond: Book.PatternMatching.Second.Value: Second.Value = Shared(99: Int);
  expect ✓describe_firstdescribe_first: fn(Book.PatternMatching.First.Value) -> String(firstfirst: Book.PatternMatching.First.Value) == "first:4""first:4": String;
  expect ✓describe_seconddescribe_second: fn(Book.PatternMatching.Second.Value) -> String(secondsecond: Book.PatternMatching.Second.Value) == "second:9""second:9": String;

  let wrapwrap: fn(Int) -> Book.PatternMatching.First.Value = First.SharedShared: fn(Int) -> Book.PatternMatching.First.Value;
  expect ✓describe_firstdescribe_first: fn(Book.PatternMatching.First.Value) -> String(wrapwrap: fn(Int) -> Book.PatternMatching.First.Value(77: Int)) == "first:7""first:7": String;

  let jsonjson: Wire.Value: Wire.Value = Bool(TrueTrue: Bool);
  expect ✓match jsonjson: Wire.Value {
    | Wire.Value.Bool(True) -> "json:true""json:true": String
    | _ -> "json:other""json:other": String
  } == "json:true""json:true": String;

Fallible expressions

error arms match the exception channel of a fallible expression. Failures are not wrapped in a Result value. exception names a reusable structural row of tags. Handling every listed tag removes those failures from the caller's inferred type.

exception LookupFailure = [
  | :missing(Int)
  | :archived(Int)
]

let lookup_namelookup_name: fn(id: Int) -> String, exn [ :archived(Int) | :missing(Int) ] = fn(idid: Int) {
  match idid: Int {
    | 1 -> "Ada""Ada": String
    | 2 -> error :archived(idid: Int)
    | _ -> error :missing(idid: Int)
  }
};

let describe_lookupdescribe_lookup: fn(id: Int) -> String = fn(idid: Int) {
  match lookup_namelookup_name: fn(id: Int) -> String, exn [ :archived(Int) | :missing(Int) ](idid: Int) {
    | namename: String -> namename: String
    | error :missing(missing_idmissing_id: Int) -> "missing:""missing:": String ++ Int.to_stringto_string: pure fn!(value: Int) -> String(missing_idmissing_id: Int)
    | error :archived(archived_idarchived_id: Int) -> "archived:""archived:": String ++ Int.to_stringto_string: pure fn!(value: Int) -> String(archived_idarchived_id: Int)
  }
};

Error Patterns

test✓
  ✓lookup_namelookup_name: fn(id: Int) -> String, exn [ :archived(Int) | :missing(Int) ]: fn(Int) -> String, exn LookupFailure;
  ✓describe_lookupdescribe_lookup: fn(id: Int) -> String: fn(Int) -> String;
  expect ✓describe_lookupdescribe_lookup: fn(id: Int) -> String(11: Int) == "Ada""Ada": String;
  expect ✓describe_lookupdescribe_lookup: fn(id: Int) -> String(22: Int) == "archived:2""archived:2": String;
  expect ✓describe_lookupdescribe_lookup: fn(id: Int) -> String(9999: Int) == "missing:99""missing:99": String;

Tag families

A dotted tag is family sugar: :window.focused is the tag :window whose payload is the tag :focused. The payload belongs to the innermost tag, so :db.unique(slug) is :db(:unique(slug)). Values, patterns, and rows all use the dotted spelling. ship fmt, Display, Inspect, and diagnostics print any tag whose only payload is a tag that way.

type Input = [
  | :window.focused
  | :key.pressed(String)
]

let describe_inputdescribe_input: fn(input: Book.PatternMatching.Input) -> String = (inputinput: Book.PatternMatching.Input: Input) -> String =>
  match inputinput: Book.PatternMatching.Input {
    | :window.focused -> "focused""focused": String
    | :key.pressed(keykey: String) -> "key ""key ": String ++ keykey: String
  };

let in_windowin_window: fn(event: 'a) -> [> :window('a) ] = (eventevent: 'a) => :window(eventevent: 'a);

Tag Families

test✓
  let focusedfocused: [> :window([> :focused ]) ] = :window.focused;
  expect ✓focusedfocused: [> :window([> :focused ]) ] == in_windowin_window: fn(event: [> :focused ]) -> [> :window([> :focused ]) ](:focused);
  expect ✓describe_inputdescribe_input: fn(input: Book.PatternMatching.Input) -> String(focusedfocused: [> :window([> :focused ]) ]) == "focused""focused": String;
  expect ✓describe_inputdescribe_input: fn(input: Book.PatternMatching.Input) -> String(in_windowin_window: fn(event: [ :focused ]) -> [> :window.focused ](:focused)) == "focused""focused": String;
  expect ✓describe_inputdescribe_input: fn(input: Book.PatternMatching.Input) -> String(:key.pressed("k""k": String)) == "key k""key k": String;
  expect ✓Display.displaydisplay: pure fn!('a: Display)(value: [> :window([> :focused ]) ]) -> String(in_windowin_window: fn(event: [> :focused ]) -> [> :window([> :focused ]) ](:focused)) == ":window.focused"":window.focused": String;
  expect ✓Inspect.inspectinspect: pure fn!('a: Inspect)(value: [> :db([> :unique(String) ]) ]) -> String(:db.unique("ada""ada": String)) == ":db.unique(\"ada\")"":db.unique(\"ada\")": String;

A family's members are an ordinary row, so a named row can fill the payload: exn [ :db(UniqueFailure) ] is the row [ :db.unique(String) ].

exception UniqueFailure = [
  | :unique(String)
]

let claimclaim: fn(slug: String) -> String, exn [ :db.unique(String) ] = fn(slugslug: String: String) -> String, exn [ :db(UniqueFailure) ] {
  if slugslug: String == "taken""taken": String {
    error :db.unique(slugslug: String);
  }
  slugslug: String
};

let claim_or_suffixclaim_or_suffix: fn(slug: String) -> String = fn(slugslug: String) {
  match claimclaim: fn(slug: String) -> String, exn [ :db.unique(String) ](slugslug: String) {
    | claimedclaimed: String -> claimedclaimed: String
    | error :db.unique(takentaken: String) -> takentaken: String ++ "-2""-2": String
  }
};

Family Rows

test✓
  ✓claimclaim: fn(slug: String) -> String, exn [ :db.unique(String) ]: fn(String) -> String, exn [ :db.unique(String) ];
  ✓claim_or_suffixclaim_or_suffix: fn(slug: String) -> String: fn(String) -> String;
  expect ✓claim_or_suffixclaim_or_suffix: fn(slug: String) -> String("ada""ada": String) == "ada""ada": String;
  expect ✓claim_or_suffixclaim_or_suffix: fn(slug: String) -> String("taken""taken": String) == "taken-2""taken-2": String;

Members of one family share their tag. A row naming several members holds one :db whose payload is the row of those members, so [ :db.unique(String) | :db.not_found(Int) ] is [ :db([ :not_found(Int) | :unique(String) ]) ]. Raising a member raises exactly that member, so find_slug infers the written row. Handling one member leaves the others in the caller's exception row.

let find_slugfind_slug: fn(slug: String, id: Int) -> String, exn [ :db([ :not_found(Int) | :unique(String) ]) ] = fn(slugslug: String: String, idid: Int: Int) {
  if slugslug: String == "taken""taken": String {
    error :db.unique(slugslug: String);
  }
  if idid: Int == 00: Int {
    error :db.not_found(idid: Int);
  }
  slugslug: String
};

let find_or_suffixfind_or_suffix: fn(slug: String, id: Int) -> String, exn [ :db.not_found(Int) ] = fn(slugslug: String, idid: Int) {
  match find_slugfind_slug: fn(slug: String, id: Int) -> String, exn [ :db([ :not_found(Int) | :unique(String) ]) ](slugslug: String, idid: Int) {
    | foundfound: String -> foundfound: String
    | error :db.unique(takentaken: String) -> takentaken: String ++ "-2""-2": String
  }
};

let describe_finddescribe_find: fn(slug: String, id: Int) -> String = fn(slugslug: String, idid: Int) {
  match find_or_suffixfind_or_suffix: fn(slug: String, id: Int) -> String, exn [ :db.not_found(Int) ](slugslug: String, idid: Int) {
    | foundfound: String -> foundfound: String
    | error :db.not_found(missingmissing: Int) -> "missing ""missing ": String ++ Int.to_stringto_string: pure fn!(value: Int) -> String(missingmissing: Int)
  }
};

Family Members

test✓
  ✓find_slugfind_slug: fn(slug: String, id: Int) -> String, exn [ :db([ :not_found(Int) | :unique(String) ]) ]: fn(String, Int) -> String, exn [ :db.unique(String) | :db.not_found(Int) ];
  ✓find_slugfind_slug: fn(slug: String, id: Int) -> String, exn [ :db([ :not_found(Int) | :unique(String) ]) ]: fn(String, Int) -> String, exn [ :db([ :not_found(Int) | :unique(String) ]) ];

  ✓find_or_suffixfind_or_suffix: fn(slug: String, id: Int) -> String, exn [ :db.not_found(Int) ]: fn(String, Int) -> String, exn [ :db.not_found(Int) ];

  ✓describe_finddescribe_find: fn(slug: String, id: Int) -> String: fn(String, Int) -> String;
  expect ✓describe_finddescribe_find: fn(slug: String, id: Int) -> String("ada""ada": String, 11: Int) == "ada""ada": String;
  expect ✓describe_finddescribe_find: fn(slug: String, id: Int) -> String("taken""taken": String, 11: Int) == "taken-2""taken-2": String;
  expect ✓describe_finddescribe_find: fn(slug: String, id: Int) -> String("ada""ada": String, 00: Int) == "missing 0""missing 0": String;

Arms bound each level of a family the way they bound any open row. Without a wildcard a level holds at most the tags its arms name, so window_state is exhaustive. A wildcard inside a payload opens that level only, and a top-level _ opens the outer row.

let window_statewindow_state: fn([< :window([< :blurred | :focused ]) ]) -> String = function {
  | :window.focused -> "focused""focused": String
  | :window.blurred -> "blurred""blurred": String
};

let db_messagedb_message: fn([< :db([> :not_found(Int) | :unique(String) ]) ]) -> String = function {
  | :db.unique(slugslug: String) -> "taken ""taken ": String ++ slugslug: String
  | :db.not_found(idid: Int) -> "missing ""missing ": String ++ Int.to_stringto_string: pure fn!(value: Int) -> String(idid: Int)
  | :db(_) -> "database""database": String
};

let any_messageany_message: fn([> :db([> :unique(String) ]) ]) -> String = function {
  | :db.unique(slugslug: String) -> "taken ""taken ": String ++ slugslug: String
  | :db(_) -> "database""database": String
  | _ -> "other""other": String
};

Family Bounds

test✓
  ✓window_statewindow_state: fn([< :window([< :blurred | :focused ]) ]) -> String: fn([ < :window([ < :blurred | :focused ]) ]) -> String;
  expect ✓window_statewindow_state: fn([< :window([< :blurred | :focused ]) ]) -> String(:window.blurred) == "blurred""blurred": String;

  ✓db_messagedb_message: fn([< :db([> :not_found(Int) | :unique(String) ]) ]) -> String: fn([ < :db([ > :not_found(Int) | :unique(String) ]) ]) -> String;
  expect ✓db_messagedb_message: fn([< :db([> :not_found(Int) | :unique(String) ]) ]) -> String(:db.not_found(77: Int)) == "missing 7""missing 7": String;
  expect ✓db_messagedb_message: fn([< :db([> :not_found(Int) | :unique(String) ]) ]) -> String(:db.timeout) == "database""database": String;

  ✓any_messageany_message: fn([> :db([> :unique(String) ]) ]) -> String: fn([ > :db([ > :unique(String) ]) ]) -> String;
  expect ✓any_messageany_message: fn([> :db([> :unique(String) ]) ]) -> String(:key.pressed("k""k": String)) == "other""other": String;

Coverage checks each level of a family. Arms naming every member of a declared family are exhaustive with no wildcard, and a missing member is reported in dotted form, such as missing case: :window.minimized.

type WindowEvent = [
  | :window.focused
  | :window.blurred
  | :window.minimized
]

let window_labelwindow_label: fn(event: Book.PatternMatching.WindowEvent) -> String = (eventevent: Book.PatternMatching.WindowEvent: WindowEvent) =>
  match eventevent: Book.PatternMatching.WindowEvent {
    | :window.focused -> "focused""focused": String
    | :window.blurred -> "blurred""blurred": String
    | :window.minimized -> "minimized""minimized": String
  };

Family Coverage

test✓
  ✓window_labelwindow_label: fn(event: Book.PatternMatching.WindowEvent) -> String: fn(:window([ :blurred | :focused | :minimized ])) -> String;
  expect ✓window_labelwindow_label: fn(event: Book.PatternMatching.WindowEvent) -> String(:window.minimized) == "minimized""minimized": String;

Rejected programs shows the family diagnostics: dotted missing cases and members whose payloads conflict.

Byte ranges

Byte ranges are ordinary arms: '0'..'9'.

let classify_byteclassify_byte: fn(Byte) -> String = function {
  | '0'..'9' -> "digit""digit": String
  | 'a'..'f' -> "hex""hex": String
  | _ -> "other""other": String
};

Byte Ranges

test✓
  expect ✓classify_byteclassify_byte: fn(Byte) -> String('7''7': Byte) == "digit""digit": String;
  expect ✓classify_byteclassify_byte: fn(Byte) -> String('c''c': Byte) == "hex""hex": String;
  expect ✓classify_byteclassify_byte: fn(Byte) -> String('x''x': Byte) == "other""other": String;

A range whose start is after its end is rejected.

crates/ship-types/tests/golden/check-byte-range-patterns/run.t

Reversed bounds are rejected.

✓
let classify = (byte: Byte) -> String =>
  match byte {
    | 'z'..'a' -> "bad"
    | _ -> "other"
  };
$ship-types-test reversed.shp
error: invalid byte range pattern
  --> reversed.shp:3:7
    |
  3 |     | 'z'..'a' -> "bad"
    |       ^^^^^^^^ range start must be <= range end