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
DrivingAgewith a plain number —age + 1— and the result is an ordinaryInteger(18), not aDrivingAge, so the rule is not checked; build aDrivingAgefrom the result if you need one. Combine twoDrivingAgevalues —age1 + age2— and the result is aDrivingAge, so the rule is checked:DrivingAge(60) + DrivingAge(60)reaches120and panics withConstraint violation. The operators that change a value in place —++,--,+=,-=and the rest — keep it aDrivingAge:age++on17gives18, butage--on16, orage += 5on100, panics. Once a value is aDrivingAge, 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:
- Use
DrivingAge(x)when the value should already be valid. If it is not, that is a bug, and a panic stops the program where the bug is. - Use
DrivingAge().of(x)at the edge of the program, where bad input is normal. You get a set-or-unset value to test with?, and the program stays in control.
The constructor states a fact you are sure of; the factory asks a question you do not yet know the answer to.
Note. The
.offactory has the same shape for every constrained type:Email().of(text),Ratio().of(n),Budget().of(amount). Build a blank one withType(), then call.of(value)to try a value safely. An unset input gives an unset result.
Try it yourself
-
Add a
Percentagetype, written on two lines likeDrivingAge:Percentage as Integer constrain as >= 0 and <= 100
Build a valid one directly and print it, then pass
-10and250through.ofand check each with?. -
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. -
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.