What is the difference between classes and records in EK9?
← Classes and OOP · Ref: Q97
Classes and records serve different purposes in EK9. Classes encapsulate behaviour with private properties and methods. Records expose data with public properties and are ideal for eliminating data clumps.
KEY DIFFERENCES
Classes: properties always private, behaviour-focused, methods provide controlled access, can have methods.
Records: properties always public, data-focused, direct field access, constructors and operators only (no methods).
Both support constructors, operators, and inheritance.
INHERITANCE — SAME OPT-IN RULE FOR BOTH
Records, like classes, are closed-by-default. Mark a base record (or class) 'as open' to allow extension. A child can override operators and call 'super.<op>(...)' to chain into the parent. See Q1297 for the full record-to-record inheritance pattern. See Q101 for closed-by-default rationale, Q102 for 'as open' details.
RECORDS ELIMINATE DATA CLUMPS
When multiple parameters appear together across several functions and methods, that is a data clump. Introducing a record groups them into a named type:
defines record Coordinate x <- 0.0 y <- 0.0
Instead of passing x and y separately to every function, pass a single Coordinate. This reduces parameter counts, improves readability, and gives the group a meaningful name.
MUTABILITY VIA PURE, NOT IMMUTABLE TYPES
Unlike Java records (immutable) or Kotlin data classes (val vs var), EK9 records are mutable. Mutability is controlled at the function and method level via 'as pure', not at the data structure level. A pure function cannot modify any of its parameters, so the same record is safely mutable in normal code and protected in pure contexts. This preserves the Liskov Substitution Principle: a mutable subtype can always be used where the parent is expected. Languages that make records immutable break LSP because an immutable subtype cannot substitute for a mutable parent.
WHEN TO USE WHICH
Use a record when: you have a data clump, need a data transfer object, or want transparent data structures that functions operate on.
Use a class when: you need to hide implementation details, enforce invariants through methods, or have complex behaviour tied to state.
See Q93 for class basics. See Q95 for visibility details. See Q96 for operators across constructs. See Q98 for record operators. See Q116 for default operator. See Q119 for constructs overview. See Q241 for mutation operators.
Example
defines module qa.oop.classvsrecord defines record <?- Groups x and y into a named type, eliminating the data clump of passing two separate Float parameters everywhere. -?> Coordinate x <- 0.0 y <- 0.0 Coordinate() -> x as Float y as Float this.x: x this.y: y default operator defines class Position x <- 0.0 y <- 0.0 Position() -> x as Float y as Float this.x: x this.y: y x() as pure <- rtn as Float: x y() as pure <- rtn as Float: y default operator defines function <?- Pure function: Coordinate is safely passed in without risk of mutation. No immutable type needed. -?> distanceFromOrigin() as pure -> point as Coordinate <- rtn as Float: sqrt(point.x * point.x + point.y * point.y) <?- Normal function: can modify the record freely. Same type works in both pure and non-pure contexts. -?> translate() -> point as Coordinate dx as Float dy as Float point.x += dx point.y += dy defines program ClassVsRecordDemo() stdout <- Stdout() // === RECORD: public fields, direct access === coord <- Coordinate(3.0, 4.0) stdout.println(`Record x: ${coord.x}, y: ${coord.y}`) // === RECORDS ELIMINATE DATA CLUMPS === // Instead of passing x and y separately, pass Coordinate dist <- distanceFromOrigin(coord) stdout.println(`Distance from origin: ${dist}`) // === PURE CONTROLS MUTABILITY, NOT THE TYPE === // Same Coordinate is mutable here, protected in pure functions translate(coord, 1.0, 2.0) stdout.println(`After translate: ${coord.x}, ${coord.y}`) // === CLASS: private fields, accessor methods === pos <- Position(3.0, 4.0) stdout.println(`Class x: ${pos.x()}, y: ${pos.y()}`) // === BOTH support operators === coord2 <- Coordinate(3.0, 4.0) stdout.println(`Records equal: ${coord == coord2}`) pos2 <- Position(3.0, 4.0) stdout.println(`Classes equal: ${pos == pos2}`) // === Records ideal for data transfer === stdout.println(`Coordinate: ${coord}`) stdout.println(`Position: ${pos}`)
Common mistakes
E06180 — Class properties are private by default. Accessing 'x' directly on a Position class instance fails; use the accessor method 'x()' instead. Records like Coordinate allow 'coord.x' directly. See ek9 -h E06180 for details.
Incorrect:
pos.x
Correct:
pos.x()
E50001 — There is no 'Point' type in this module. EK9 type names are case-sensitive and must match exactly. The class is called 'Position', not 'Point'. See ek9 -h E50001 for details.
Incorrect:
pos <- Point(3.0, 4.0)
Correct:
pos <- Position(3.0, 4.0)
E07290 — Records can only have constructors and operators, not methods. Adding a method such as 'getName()' triggers E07290. Use a standalone function or switch to a class if you need methods. See ek9 -h E07290 for details.
Incorrect:
getName()
<- rtn as String: name
Correct:
default operator
E50020 — A class cannot extend a record because they are different construct types (genus). Classes extend classes and records extend records. Use composition instead of cross-genus inheritance. See ek9 -h E50020 for details.
Incorrect:
Position extends Coordinate
Correct:
Position
x <- 0.0
y <- 0.0
E50060 — EK9 does not have a 'toString()' method. Use the $ operator for string conversion: '$coord' or string interpolation '`${coord}`'. See ek9 -h E50060 for details.
Incorrect:
stdout.println(coord.toString())
Correct:
stdout.println(`Coordinate: ${coord}`)
E50060 — EK9 does not use Java-style 'getX()' accessors. Class accessor methods match the property name: 'pos.x()' not 'pos.getX()'. See ek9 -h E50060 for details.
Incorrect:
stdout.println(`Class x: ${pos.getX()}, y: ${pos.y()}`)
Correct:
stdout.println(`Class x: ${pos.x()}, y: ${pos.y()}`)
Other ways to ask this
- When should I use a record instead of a class in EK9?
- How do records differ from classes in EK9?
- What are EK9 records used for?
Coming from another language?
Java: records (Java 16+) are immutable with auto-generated accessors, breaking LSP for mutable hierarchies. Python: dataclasses are mutable by default, frozen=True makes them immutable. Rust: structs are mutable by default, 'mut' controls mutability at the binding level. Go: only structs, all mutable, no immutability mechanism. Kotlin: data classes use val (immutable) or var (mutable) per field. Swift: structs are value types (copied on assignment), can have methods and computed properties unlike EK9 records, recommended over classes for most data types. EK9: records are mutable, mutability is controlled by 'as pure' on functions and methods rather than at the data structure level. This preserves LSP and separates the concern of data shape from data protection.
Keywords: Liskov, data clump, class, pure, encapsulation, swift, behaviour, transfer, struct, record, private, visibility, public, data, difference, mutability, immutable, object-oriented