How do record operators work in EK9?

← Classes and OOP · Ref: Q98

EK9 records combine public fields with constructors AND operators, making them first-class in collections, serialisation, and comparison without external utility functions.

COMPARISON OPERATORS

  <=> for ordering (sorting), == and <> for equality, <, >, <=, >= for relational.

Enables stream sort, PriorityQueue, and generic ordered code.

CONVERSION OPERATORS

  $ to String (display), $$ to JSON (serialisation), #? hashcode (Dict keys).

MUTATION OPERATORS

  :=: deep copies all fields. :~: merges only SET fields (partial updates). :^: replaces entire content.

DEFAULT OPERATOR

  default operator

Auto-generates all standard operators from fields. One line for full collection, serialisation, and comparison support.

NO METHODS ON RECORDS

Records have only constructors and operators. Use functions or classes for methods.

See Q96 for operators across constructs. See Q97 for records vs classes. See Q116 for default operator. See Q120 for sorting. See Q238 for the fixed operator set. See Q241 for mutation rules.

Example

defines module qa.oop.recordops

  defines record

    <?-
      A record with the full operator suite.
      These operators make it work naturally
      in collections, streams, and serialisation.
    -?>
    Config
      host <- String()
      port <- Integer()

      Config()
        ->
          host as String
          port as Integer
        this.host: host
        this.port: port

      // === COMPARISON: enables sorting and deduplication ===

      operator <=> as pure
        -> other as Config
        <- rtn as Integer: host <=> other.host

      operator == as pure
        -> other as Config
        <- rtn as Boolean: host == other.host and port == other.port

      operator <> as pure
        -> other as Config
        <- rtn as Boolean: not (this == other)

      // === CONVERSION: enables display, logging, JSON, Dict keys ===

      operator $ as pure
        <- rtn as String: `${host}:${port}`

      operator #? as pure
        <- rtn as Integer: #?host + #?port

      // === MUTATION: enables structured data transfer ===

      operator :=:
        -> from as Config
        host :=: from.host
        port :=: from.port

      operator :~:
        -> from as Config
        if from.host?
          host :=: from.host
        if from.port?
          port :=: from.port

      operator :^:
        -> from as Config
        host :=: from.host
        port :=: from.port

      override operator ? as pure
        <- rtn as Boolean: host? and port?

  defines record

    <?-
      A record using 'default operator' to get
      the full suite auto-generated from fields.
    -?>
    Endpoint
      name <- String()
      url <- String()
      priority <- Integer()

      Endpoint()
        ->
          name as String
          url as String
          priority as Integer
        this.name: name
        this.url: url
        this.priority: priority

      default operator

  defines program

    RecordOpsDemo()
      stdout <- Stdout()

      // === COMPARISON: records sort naturally in collections ===

      configs <- [
        Config("zeta-host", 9090),
        Config("alpha-host", 8080),
        Config("mu-host", 3000)
        ]

      sorted <- cat configs | sort | collect as List of Config
      stdout.println("Sorted configs:")
      cat sorted > stdout

      // === EQUALITY: deduplication and lookup ===

      c1 <- Config("localhost", 8080)
      c2 <- Config("localhost", 8080)
      c3 <- Config("production", 443)
      stdout.println(`Equal: ${c1 == c2}`)
      stdout.println(`Different: ${c1 <> c3}`)

      // === STRING AND HASHCODE: display, logging, Dict keys ===

      stdout.println(`Config: ${c1}`)
      stdout.println(`Hash: ${#?c1}`)

      // === COPY: full deep copy ===

      backup <- Config()
      backup :=: c1
      stdout.println(`Backup: ${backup}`)

      // === MERGE: partial update (only set fields) ===

      partial <- Config()
      partial.host: "override-host"
      target <- Config("original", 3000)
      target :~: partial
      stdout.println(`After merge: ${target}`)

      // === REPLACE: full replacement ===

      replacement <- Config("new-host", 9090)
      target :^: replacement
      stdout.println(`After replace: ${target}`)

      // === DEFAULT OPERATOR: auto-generated full suite ===

      endpoints <- [
        Endpoint("api", "https://api.example.com", 1),
        Endpoint("web", "https://www.example.com", 2),
        Endpoint("api", "https://api.example.com", 1)
        ]

      first <- endpoints.getOrDefault(0, Endpoint())
      third <- endpoints.getOrDefault(2, Endpoint())
      stdout.println(`First equals third: ${first == third}`)

      sortedEndpoints <- cat endpoints | sort | collect as List of Endpoint
      stdout.println("Sorted endpoints:")
      cat sortedEndpoints > stdout

Common mistakes

E07290 — Records can only have constructors and operators, not methods. Adding a method like getDisplay() triggers E07290. Use operators for conversion ($, $$) and access fields directly (config.host). If you need methods, use a class instead. See ek9 -h E07290 for details.

Incorrect:

      getDisplay() as pure
        <- rtn as String: host

      // === COMPARISON: enables sorting and deduplication ===

Correct:

      // === COMPARISON: enables sorting and deduplication ===

E07235 — Records with fields must define operator ? for tri-state semantics. Without it, the record cannot participate in guard expressions or coalescing. Use 'default operator', 'default operator ?', or implement manually. See ek9 -h E07235 for details.

Incorrect:

  defines record

Correct:

      override operator ? as pure
        <- rtn as Boolean: host? and port?

  defines record

E07500 — Comparison operators must be marked 'as pure' because they should not have side effects. Omitting 'as pure' on ==, <>, <=>, <, >, <=, >= triggers E07500. See ek9 -h E07500 for details.

Incorrect:

operator <=>

Correct:

operator <=> as pure
Other ways to ask this
  • What operators can records have in EK9?
  • How do :=: and :~: and :^: operators work on records?
  • Why are record operators important for collections?
  • How do records support sorting, comparison, and serialisation?

Coming from another language?

Java: records (16+) immutable, no copy/merge. Python: dataclasses, no merge. Rust: derive macros, no merge. Go: structs have no operators. EK9: records carry comparison, conversion, mutation operators. 'default operator' generates full suite from fields.

Keywords: data clump, merge, collection, comparison, operator, copy, serialisation, field, sort, mutation, hashcode, stream, replace, JSON, record, pipeline, string, default