Getting Started with EK9 · Chapter 15

Constrained Types

An enumeration can only hold one of the names you listed: a Day is Monday to Sunday and nothing else, so a wrong value cannot exist at all. This chapter applies the same idea to familiar types. A driving age is an Integer, but not any Integer: it is one between 16 and 100. A constrained type lets you say exactly that, and the compiler and the running program hold you to it.

Declaring one

A constrained type is a new, named type built on an existing one, with a rule attached:

  defines type
    DrivingAge as Integer constrain as
      >= 16 and <= 100

DrivingAge is an Integer constrained to values >= 16 and <= 100. It is a distinct type with its own name, but it keeps everything an Integer can do: you can add it, compare it, print it and ask whether it is set. What it adds is the rule: no DrivingAge may hold a number outside 16 to 100.

The rule can be any condition the underlying type understands. Numbers take ranges; text takes a pattern (both are in Rules.ek9):

    Ratio as Float constrain as
      >= 0.0 and <= 1.0

    Email as String constrain as
      matches /^[a-z]+@[a-z]+$/

The rule always goes on its own line, indented under constrain as; writing it on the same line is a syntax error.

Integer, Float and String can be constrained this way, and so can Money, Date, Time, Dimension and Colour. The underlying type brings its behaviour; you bring the boundary. You cannot constrain a type that is already constrained — build on Integer, not on an age type you made earlier. Layering one constraint on another is an error (E04010): one flat rule is easier to read, and to trust, than a stack of them.

Building one, the direct way

You make a DrivingAge with its constructor:

#!ek9
defines module book.constrain

  defines type
    DrivingAge as Integer constrain as
      >= 16 and <= 100

  defines function
    verdict()
      -> age as DrivingAge
      <- rtn as String: `${age} is a valid driving age`

  defines program
    Show()
      stdout <- Stdout()

      // Direct construction, in range — behaves like the Integer underneath
      age <- DrivingAge(17)
      stdout.println(`age = ${age}   (set? ${age?})`)
      stdout.println(`next year: ${age + 1}`)
      stdout.println(verdict(age))

      // Safe construction from an untrusted value with the .of factory
      for raw in [20, 9, 133]
        candidate <- DrivingAge().of(raw)
        if candidate?
          stdout.println(`${raw}: accepted -> ${candidate}`)
        else
          stdout.println(`${raw}: rejected (out of range)`)

It prints:

age = 17   (set? true)
next year: 18
17 is a valid driving age
20: accepted -> 20
9: rejected (out of range)
133: rejected (out of range)

The first three lines come from DrivingAge(17), and show Integer's operators at work: age + 1 gives 18, ${age} prints 17, age? is true. The last three come from DrivingAge().of(raw), a second way to build one that gives an unset value instead of stopping the program; Building one, the safe way, below, explains it.

Now look at verdict, a function that takes a DrivingAge. Its parameter is not "an Integer I hope is a sensible age"; it is a DrivingAge, and the type system guarantees that any value reaching it is already in range. The check happened once, when the value was built. Inside verdict, and anywhere else a DrivingAge goes, there is nothing left to validate: a function that takes a DrivingAge can never be handed -5.

It is not an Integer with a label

If a DrivingAge has all of Integer's operators, can you use one as an Integer — assign it to an Integer variable, or pass it to a function that wants one? It is a natural thing to try:

      age <- DrivingAge(17)
      years as Integer: age          // assign to an Integer
      r <- takesInteger(age)         // pass where an Integer is wanted

Neither compiles (the whole program is notAnInteger.ek9, in the error-examples folder):

EK9     : Error   : E50030: 'years' on line 16 position 6: 'years as Integer' on line 16 in 'notAnInteger.ek9' and 'age as DrivingAge': types are not compatible with each other
             See: ek9 -h E50030
EK9     : Error   : E06270: 'takesInteger' on line 17 position 11: found function 'Integer <- takesInteger(n as Integer)' but used with supplied arguments '(DrivingAge)' not appropriate: parameter mismatch
             See: ek9 -h E06270
           forAI: methodResolution

A DrivingAge is not an Integer. It is a distinct type, built on Integer and sharing its operators, but not interchangeable with it. If a DrivingAge could stand in for any Integer unnoticed, the guarantee would be lost the first time one passed through general-purpose number code.

When you do want the plain number, you ask for it with the promotion operator #^:

#!ek9
defines module book.promote

  defines type
    DrivingAge as Integer constrain as
      >= 16 and <= 100

  defines function
    takesInteger()
      -> n as Integer
      <- rtn as Integer: n + 1

  defines program
    Show()
      stdout <- Stdout()
      age <- DrivingAge(17)

      // Deliberately take the plain Integer value with #^ — a fresh, independent copy
      plain as Integer: #^age
      plain += 5
      stdout.println(`age stays ${age}, plain is now ${plain}`)

      // Hand the promotion to code that wants a plain Integer
      stdout.println(`takesInteger(#^ age) = ${takesInteger(#^age)}`)
age stays 17, plain is now 22
takesInteger(#^ age) = 18

#^ age promotes the DrivingAge to a plain Integer, and it does so by returning a new, independent Integer holding a copy of the value. The first line of output shows it: adding 5 to plain leaves age at 17. Promotion produces a separate value and leaves the constraint behind, which is why EK9 makes you write it rather than doing it unseen.

The other direction you already know: to turn an Integer into a DrivingAge you build one — DrivingAge(x) or DrivingAge().of(x) — and the constraint is checked as you do. So the only way in is to build (and be checked), and the only way out is to promote with #^ (and get a separate copy). Nothing crosses unnoticed.

Note. Arithmetic depends on what you combine. Combine a DrivingAge with a plain number — age + 1 — and the result is an ordinary Integer (18), not a DrivingAge, so the rule is not checked; build a DrivingAge from the result if you need one. Combine two DrivingAge values — age1 + age2 — and the result is a DrivingAge, so the rule is checked: DrivingAge(60) + DrivingAge(60) reaches 120 and panics with Constraint violation. The operators that change a value in place — ++, --, +=, -= and the rest — keep it a DrivingAge: age++ on 17 gives 18, but age-- on 16, or age += 5 on 100, panics. Once a value is a DrivingAge, none of them can take it out of range.

When the value is wrong

What happens when a value breaks the rule depends on whether the compiler can see the problem.

If the compiler can work out the value — a plain number that is out of range — it will not build the program:

      bad <- DrivingAge(5)
EK9     : Error   : E08260: '5' on line 11 position 24: '5' violates the constraint of 'DrivingAge' compile-time constant value violates the type's constraint — this would Panic at runtime
             See: ek9 -h E08260

(That is constraintCompile.ek9, in the error-examples folder.)

The message says what would have happened: a panic when the program ran. Because the compiler could prove the value wrong, it stops you first.

Often, though, the value is not known until the program runs — it comes from a person, a file or a form. The next program stands in for that by parsing the text "5" into an Integer, which to the compiler looks exactly like input from a keyboard or a file: a number it cannot see in advance. The check then happens while the program runs (constraintRuntime.ek9, in the error-examples folder):

#!ek9
defines module book.panicdemo2

  defines type
    DrivingAge as Integer constrain as
      >= 16 and <= 100

  defines program
    Show()
      stdout <- Stdout()
      stdout.println("about to build a bad age...")
      raw <- Integer("5")
      bad <- DrivingAge(raw)
      stdout.println(`built: ${bad}`)

Running it prints:

about to build a bad age...
Exception in thread "main" Panic: Constraint violation for book.panicdemo2::DrivingAge: Exit Code: 1

The program printed its first line, then panicked — stopped at once — when it tried to build a DrivingAge from 5. The line that would have printed the value never ran. Rather than let an invalid DrivingAge into the program, where it could cause a wrong answer somewhere far away, EK9 refuses to create it. A loud stop at the source is easier to fix than a quiet error later.

Building one, the safe way

A panic is the right response to a bug: a value that should never have been out of range. At the edge of a program, though, bad input is expected. People mistype; files contain bad rows. For that case a constrained type has a second way to build one, the .of factory:

      candidate <- DrivingAge().of(raw)
      if candidate?
        stdout.println(`${raw}: accepted -> ${candidate}`)
      else
        stdout.println(`${raw}: rejected (out of range)`)

DrivingAge().of(raw) never panics. If raw is in range you get a set DrivingAge; if not, you get an unset one, and ? tells the two apart. That is why the loop over [20, 9, 133] in the first program runs to the end: 20 is accepted, 9 and 133 are rejected, and nothing stops. It is the same check-before-use habit as Optional and Result in Optional and Result, applied to building your own type.

So there are two ways to build, for two situations:

The constructor states a fact you are sure of; the factory asks a question you do not yet know the answer to.

Note. The .of factory has the same shape for every constrained type: Email().of(text), Ratio().of(n), Budget().of(amount). Build a blank one with Type(), then call .of(value) to try a value safely. An unset input gives an unset result.

Try it yourself

  1. Add a Percentage type, written on two lines like DrivingAge:

        Percentage as Integer constrain as
          >= 0 and <= 100

    Build a valid one directly and print it, then pass -10 and 250 through .of and check each with ?.

  2. Write a Username, again on two lines:

        Username as String constrain as
          matches /^[a-z][a-z0-9]+$/

    Try .of("alice"), .of("2cool") and .of("Bob"), and see which are accepted.

  3. Write DrivingAge(5) directly and read the compile error. Then take the number from a program argument instead, so the compiler cannot see it, and see where the check happens now.

Recap

A constrained type is a named type built on a familiar one — DrivingAge as Integer constrain as >= 16 and <= 100 — that keeps the underlying type's operations and adds a rule about which values are allowed. Build one directly with DrivingAge(x) when the value should already be valid: a provably bad literal is a compile error (E08260), and a bad value found while running panics. Build one safely at the edges with DrivingAge().of(x), which returns a set-or-unset value you check with ?. A constrained type is a distinct type, not a relabelled Integer: it will not stand in for one, and you get the plain value out by promoting it with #^, which gives a separate copy. With enumerations, constrained types let you make illegal states unrepresentable: not "please pass a sensible age" in a comment, but a type that cannot hold a wrong one. Next, Classes: data protected not by a range but by the behaviour you attach to it.