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.

A CONSTRAINT'S LITERAL MUST MATCH EXACTLY

The literal in a 'constrain as' is compared with the base type's operator exactly as written - it is never promoted. A Float constraint writes '>= 1.0', not '>= 1' (E08265), and a String constraint writes '<> "x"', not '<> 'x''. For a user-defined base you may instead add the operator that takes the literal's type. See Q1391.

RANGE CONSTRAINTS WITH CONSTRAIN

The 'constrain' syntax restricts numeric values to a range:

  defines type
    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, as in 'describeAnimal() of type T constrain by Animal as open'.
This preserves IS-A relationships: T must be a subtype of the bound (here the user-defined class Animal; see Q195 for the complete compiling example). 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:

  defines function
    processEmail()
      -> addr as EmailAddress
      <- rtn as Boolean: addr?
      // Only accepts validated emails
    processString()
      -> text as String
      <- rtn as Boolean: text?
      // 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

    //A Float constraint states its bound as a Float - 1.0, never 1 (E08265, see Q1391).
    Ratio as Float constrain as
      >= 1.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?}`)

      // === ASSIGN-IF-UNSET 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 — A constraint expression may only use operators that its base type provides. Person declares 'operator matches' but has no '>' operator, so the constraint '> 0' on ValidPerson cannot be resolved and the compiler reports E50060 on that line. Constrained types inherit operators from their base - a constraint that needs an operator the base does not have breaks. See ek9 -h E50060 for details.

Incorrect:

ValidPerson as Person constrain as
      > 0

Correct:

ValidPerson as Person constrain as
      matches /^[a-zA-Z]+ [a-zA-Z]+$/

E08265 — A constraint's literal must match the operator's parameter type exactly - it is never promoted. Float's '>=' takes a Float, so write 1.0. See Q1391.

Incorrect:

    Ratio as Float constrain as
      >= 1

Correct:

    Ratio as Float constrain as
      >= 1.0
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: type, type-system, constant, like-a, restrict, constraint, advanced, range, disconnected, validate, generic, pattern, constrain, validation, bound