How do constrained types work in EK9?
← Advanced Type System · Ref: Q257
EK9 has two distinct constraint mechanisms with different semantics: value constraints that create disconnected types, and generic bounds that restrict type parameters.
VALUE CONSTRAINTS WITH CONSTRAIN AS
The 'constrain as' syntax creates a NEW type constrained by a pattern or condition:
Name as String constrain as matches /^[a-zA-Z -]+$/
Name is a new type that validates its content at construction time. A bare constructor ASSERTS validity: it panics at runtime on a set invalid value (and a literal constant that provably violates is the compile error E08260). Use the fallible '.of(value)' factory for untrusted input — it returns an unset value on failure instead of panicking. Name shares operators with String (you can compare, convert to string, etc.) but it is NOT a String subtype. You cannot pass a Name where a String is expected. This is a LIKE-A relationship, not an IS-A relationship.
RANGE CONSTRAINTS WITH CONSTRAIN
The 'constrain' syntax restricts numeric values to a range:
PositiveIndex as Integer constrain > 0
PositiveIndex only accepts values greater than zero. Like 'constrain as', this creates a disconnected type.
GENERIC BOUNDS WITH CONSTRAIN BY
The 'constrain by' syntax restricts generic type parameters:
Handler of type T constrain by Comparable
This preserves IS-A relationships: T must be a subtype of the bound. Inside the generic, you can call methods from the constraining type.
WHY DISCONNECTED TYPES?
Value-constrained types are deliberately disconnected from their base type. If EmailAddress extended String, you could pass an EmailAddress anywhere a String is expected, bypassing validation. By making them disconnected, the compiler prevents accidental substitution:
processEmail()
-> addr as EmailAddress
// Only accepts validated emails
processString()
-> text as String
// EmailAddress cannot be passed here
This forces explicit conversion when crossing type boundaries.
CONSTRAINT VALIDATION
Constraints are checked at construction time. A bare constructor ASSERTS validity and panics at runtime if a set value violates the constraint (a violating literal constant is the compile error E08260). For untrusted input use the fallible '.of(value)' factory, which returns an unset value on failure instead of panicking:
badName <- Name().of("123") if badName? // Will not enter: constraint failed
Use the ? operator to check if construction succeeded.
See Q195 for generic type constraints with constrain by. See Q223 for constrained enumerations. See Q27 for static typing benefits. See Q269 for input validation with constrained types.
Example
defines module qa.advancedtypes.constrainedtypes defines class Person firstName as String? surname as String? default private Person() as pure Person() as pure -> first as String last as String firstName :=? String(first) surname :=? String(last) // Copy constructor: a constrained type (ValidPerson below) stores an INDEPENDENT copy of its // base, so the base needs a copy mechanism — either this public copy constructor Person(Person) // or a public no-arg constructor plus the ':=:' operator. Without one, ValidPerson is rejected // with E06131. Person keeps its no-arg constructor private, so the copy constructor is the fit. Person() as pure -> from as Person firstName :=? String(from.firstName) surname :=? String(from.surname) operator matches as pure -> pattern as RegEx <- rtn as Boolean: $this matches pattern operator $ as pure <- rtn as String: `${firstName} ${surname}` override operator ? as pure <- rtn as Boolean: firstName? and surname? defines type // Value constraint: Name must match letters and spaces only Name as String constrain as matches /^[a-zA-Z -]+$/ // Range constraint: PositiveIndex must be greater than zero PositiveIndex as Integer constrain > 0 // User-defined type constraint: Person must have first and last name ValidPerson as Person constrain as matches /^[a-zA-Z]+ [a-zA-Z]+$/ defines program ConstrainedTypesDemo() stdout <- Stdout() // === VALUE CONSTRAINT: Name === // Valid construction - fallible factory returns a set value goodName <- Name().of("Alice Smith") stdout.println(`Valid name set: ${goodName?}`) if goodName? stdout.println(`Name: ${goodName}`) // Invalid construction - fallible factory returns an unset value badName <- Name().of("123!@#") stdout.println(`Invalid name set: ${badName?}`) // === RANGE CONSTRAINT: PositiveIndex === // Valid: greater than zero goodIndex <- PositiveIndex().of(5) stdout.println(`Valid index set: ${goodIndex?}`) if goodIndex? stdout.println(`Index: ${goodIndex}`) // Invalid: zero is not > 0 - fallible factory returns unset badIndex <- PositiveIndex().of(0) stdout.println(`Zero index set: ${badIndex?}`) // Invalid: negative - fallible factory returns unset negIndex <- PositiveIndex().of(-3) stdout.println(`Negative index set: ${negIndex?}`) // === USER-DEFINED TYPE CONSTRAINT: ValidPerson === // Person with operator matches enables constraining goodPerson <- ValidPerson().of(Person("Alice", "Smith")) stdout.println(`Valid person set: ${goodPerson?}`) if goodPerson? stdout.println(`Person: ${goodPerson}`) // Person with numbers in name fails the regex constraint - fallible factory returns unset badPerson <- ValidPerson().of(Person("123", "456")) stdout.println(`Bad person set: ${badPerson?}`) // === GUARDED ASSIGNMENT WITH CONSTRAINTS === // Guard pattern works naturally with constrained types if validName <- Name().of("Bob Jones") stdout.println(`Guarded name: ${validName}`) if invalidName <- Name().of("") stdout.println("Should not print")
Common mistakes
E50060 — Renaming 'operator matches' to method 'doesMatch' means Person no longer has the matches operator. The constrained type ValidPerson uses matches in its constraint expression, but the cloned operator no longer exists on the base type, triggering E50060. Constrained types inherit operators from their base — remove the operator and the constraint breaks. See ek9 -h E50060 for details.
Incorrect:
doesMatch() as pure
Correct:
operator matches as pure
Other ways to ask this
- What is the difference between constrain as and constrain by?
- How do I create a type with validation constraints?
- How do I restrict the values a type can hold?
- What is a constrained type in EK9?
Coming from another language?
Java: no built-in value constraints, uses Bean Validation annotations (@Pattern, @Min). Python: no type-level constraints, runtime checks with validators. Rust: no constrained types, uses newtype pattern with manual validation. Go: no type constraints, runtime validation. Kotlin: value classes with init validation, but still subtypes. Swift: no constrained types, uses property wrappers. C#: no type-level constraints, uses data annotations. EK9: built-in 'constrain as' for pattern constraints, 'constrain' for range constraints, creates disconnected types (LIKE-A not IS-A), validation at construction time: a bare constructor asserts validity (panics on a set invalid value, or compile error E08260 for a violating literal), while the fallible '.of(value)' factory returns unset on failure for untrusted input.
Keywords: advanced, constraint, restrict, like-a, constant, type-system, type, bound, validation, constrain, pattern, generic, validate, disconnected, range