EK9 Built in Types
EK9 has a set of built-in types and a set of collection types. Both were designed in from the start and form the core of the language.
Further reading on the blog: why strong typing matters, and the case for more sophisticated types in a programming language.
The Types
- Any
- Boolean
- Character
- String
- Integer
- Float
- BigInteger
- BigDecimal
- Maths
- Bits
- Byte
- Bytes
- BytesReader
- BytesBuilder
- Digest
- HMAC
- SecureRandom
- Time
- Duration
- Millisecond
- Date
- DateTime
- Money
- Locale
- Colour
- Dimension
- Path
- JSON (JavaScript Object Notation)
- CSV (Comma Separated Values)
- CSVRow
- RegEx (Regular Expression)
- Collection Types
These are the basic building blocks. The predefined classes for specific purposes, more like a standard library, are in collection types, standard types and network types.
String and Bits are also a kind of collection: a String is an ordered list of Characters and Bits an ordered list of Booleans.
Every type here is an object, not a primitive, and is passed by reference, not by value. A variable of any type holds an object that is either set or un set: un set means the object exists but the value it holds is meaningless. The operators and basics pages cover this, and this page uses the terms of declarations.
EK9 has no static methods. Functions that return an object in a particular state, and methods that return a value without changing their object, do that job; the design note on small type APIs explains why.
Important: No 'null' Literal
EK9 has no null literal; writing null is error
E01073. Every value is an object, and the question is whether it holds a
meaningful value:
- Unset - the object exists but has no meaningful value (for example
Integer(), or the default handed back when a key is not in a dictionary) - Set - the object exists with a valid, usable value
Absent - no memory allocated to hold a value at all - is what other languages call null.
EK9 is designed so that a program can never observe it: you test for set with the ?
operator or a guard, never for null.
//Creating an unset value (NOT null) name <- String() //Unset String require not name? //_isSet() returns false //Checking if set before use chosen <- getName() if chosen? process(chosen) //Safe - guaranteed set //Guard for safe access if validName <- getName() process(validName) //Safe - guaranteed set
Full source: builtInTypes_tri_state.ek9 - Download builtInTypes_tri_state.ek9
The aim is to eliminate NullPointerException entirely: the compiler checks that every variable,
property and return value holds an object before it is read. Making that a proven guarantee (closing the known
gaps in those checks) is work in progress; Excluded Features
gives the evidence behind the approach.
Any
Any is the implicit supertype of every EK9 construct: classes, records, components, traits, functions, enumerations, constrained types and generic instantiations.
Unlike Java's Object
In Java, Object is a class and everything extends it as a class. EK9's Any preserves the 'genus' (kind) of whatever it holds:
- A function assigned to Any is still a function (it remains callable)
- A trait assigned to Any is still a trait
- A record assigned to Any is still a record
- A class assigned to Any is still a class
That makes Any a little "strange" next to other languages: it holds any construct and keeps what that construct is.
Limited Operations
Any has one operator, ? (is set), and no other methods, operators or call semantics. To work with the value you need its real type.
Type Detection with Dispatchers
EK9 has no casting and no instanceof. When you need the actual type of an Any, use a dispatcher method, which routes to the overload for the runtime type. See Advanced Class Methods.
Use Cases for Any
- Heterogeneous collections:
List of Anycan hold mixed types - Universal return type: a function can return "anything" when the exact type varies
- Dispatcher input: accept any type, then dispatch to specific handlers
//A heterogeneous list - declare List of Any (an undeclared mix sharing only Any is E50030) mixedList as List of Any: [ 1, "hello", 2024-01-15, CardSuit.Hearts ] //To process these, use a dispatcher - see Advanced Class Methods
Full source: builtInTypes_any_list.ek9 - Download builtInTypes_any_list.ek9
Boolean
A Boolean is one of:
- Unknown not set
- True
- False
Most languages have only true and false.
//Declare a Boolean - using type inference but with no value yet known. reviewAccess <- Boolean() //Declare a Boolean - with type inference with a known initial value allowModification <- false //Declare a Boolean - but with no space allocated to hold the value. provideAccess as Boolean? //Declare a Boolean - but with no value yet known. denyAccess as Boolean: Boolean() //Declare a Boolean - without type inference allowViewing as Boolean := true
Full source: builtInTypes_boolean.ek9 - Download builtInTypes_boolean.ek9
Boolean is the simplest type. Its operators are in the operators section. Use type inference wherever you can.
Character
A Character is one of:
- Unknown not set
- Any single character
- A Unicode escape sequence (four hex digits only), for example '\u00E9' for é
For a word or a sentence, use a String.
//Some examples of using Character, some with type inference c1 <- Character() c2 <- 's' c3 as Character := 'é' //Comparisons lessThan <- c1 < c2 lessOrEqual <- c1 <= c2 greaterThan <- c1 > c2 greaterOrEqual <- c1 >= c2 equal <- c1 == c2 notEqual <- c1 != c2 alsoNotEqual <- c1 <> c2 //Other operators length1 <- length c1 //results in not set length2 <- length c2 //results in 1 length3 <- length c3 //results in 1 length4 <- c3.length() //same result (1) - but written in object form hashCode1 <- #? c3 //results in 233 (hashcode method) hashCode2 <- c3.#?() //same result (233) - but written in object form //Promotion to String str1 as String := #^c2 //results String of "s" str2 <- #^c2 //also results String of "s" - uses type inference str3 <- c2.#^() //also results String of "s" - uses type inference and object form //Standard methods upper <- c2.upperCase() //results in 'S' lower <- c2.upperCase().lowerCase() //results in 's'
Full source: builtInTypes_character.ek9 - Download builtInTypes_character.ek9
String
A String is one of:
- Unknown not set
- Empty, ""
- Any number of Characters
A String is an ordered sequence of characters. It can be changed and can grow, and it has no termination character. It has the comparison and functional operators. Note the distinction between not set and "", the empty String. It has:
- empty - is the String empty, that is ""
- length - how many characters it has
- first - the first character ('#<')
- last - the last character ('#>')
- contains - see comparison
- matches - see comparison
- '<=>' - see comparison
- '<~>' - see comparison
And these methods:
- count(Character) - how many times the Character appears
- '+=' - append a String, Character or List of Character
- '|' - in a pipeline, add a String (even if the String is currently un set)
- trim - remove white space at either end
- rightPadded - pad with spaces on the right to a length
- leftPadded - pad with spaces on the left to a length
- upperCase - upper-case the String
- lowerCase - lower-case the String
- subString(start, end) and indexOf - by position, see below
ek9 -h String lists the full API.
String Literals
A String literal takes two forms: a fixed String and an interpolated String.
//Declare a fixed String literal language <- "EK9" //Declare a variable to be used in an interpolated String age <- 30 //Use the variable in an interpolated String message <- `Your age is ${age}`
Full source: builtInTypes_string_literals.ek9 - Download builtInTypes_string_literals.ek9
An interpolated String starts and ends with a back tick '`'. Inside it, '${' starts a variable, function, method or expression for the compiler to evaluate, the same syntax as JavaScript. The result must be a String, something that can be coerced to a String, or a type with a '$' String operator.
Escapes in String
In a normal String such as "A Test", escape the " character: "A \"Test\"". The back tick '`' and '$' need no escape.
A normal String is never interpolated, so it cannot hold '${': "${count} items" would print
those characters rather than the count, and the compiler rejects it
(E07901). Double quotes do interpolate in Kotlin, Groovy, Dart, PHP and
the shells, which is why it is easy to write by habit; before the error existed it compiled silently and showed
up only as wrong output. The error message spells out both rewrites:
- To interpolate, use a back tick String:
`${count} items`. - To keep the characters ${ as text, escape the dollar in a back tick String:
`\${count} items`. A backslash is not an escape for '$' in a normal String ("\${count}"keeps the backslash).
In an interpolated String the escaping is the other way round: `Your \`salary\` is \$${salary}`. Every
'$' that does not open an interpolation is written \$, including a regular expression replacement
that names a group, `\${day}.\${month}`.
A handful of Strings can sit in the source. For very large text, or many text elements in different spoken languages, use the text construct: it keeps plain and interpolated Strings in their own construct, away from the code.
Interpolation Example
See text properties for defining a large number of String text items.
#!ek9 defines module site.builtInTypes.interpolation defines record Person firstName String: String() lastName String: String() Person() -> firstName String lastName String require firstName? and lastName? this.firstName :=: firstName this.lastName :=: lastName operator $ as pure <- rtn String: `${firstName} ${lastName}` default operator ? defines function getAuthor() <- rtn Person: Person("Steve", "Limb") defines program ShowStringInterpolation() stdout <- Stdout() normal <- "With normal double quote \" text" stdout.println(`[${normal}]`) var <- 90.9 interpolated <- `A tab\t ${var} a " but a back tick \` the dollar have to be escaped \$\nOK?` stdout.println(`[${interpolated}]`) me <- Person("Steve", "Limb") stdout.println(`Author: ${me} or via function: ${getAuthor()}, direct access "${me.firstName}"`)
It prints:
[With normal double quote " text] [A tab 90.9 a " but a back tick ` the dollar have to be escaped $ OK?] Author: Steve Limb or via function: Steve Limb, direct access "Steve"
Why EK9 does it this way. The two String literals are deliberately different, " and `, so you choose one or the other. A variable in an interpolated String is always written ${...}, never a bare $. Because the two forms are distinct the compiler can hold the line between them: ${ inside a " String is an error (E07901), never quietly treated as text.
Accessing Characters in a String
To take a Character or a range of Characters from a String, use a pipeline:
#!ek9 defines module site.builtInTypes.string defines function isO() as pure -> c as Character <- o as Integer: c == 'o' or c == 'O' <- 1 else 0 defines program ShowStringType() stdout <- Stdout() i1 <- "The Quick Brown Fox" i2 <- "Jumps Over The Lazy Dog" space <- " " i3 <- String() stdout.println(`i3 is set? ${i3?}`) hashCode <- #? i1 stdout.println(`Hashcode is ${hashCode}`) sentence1 <- `${i1} ${i2}` stdout.println(`sentence1 is "${sentence1}"`) l1 <- length sentence1 l2 <- sentence1.length() //the same value, but via the method stdout.println(`Length is ${l1} or ${l2}`) oCount <- sentence1.count('o') //only the lower case 'o' eitherCaseOCount <- cat sentence1 | map with isO | collect as Integer //'O' and 'o' stdout.println(`'o' appears ${oCount} times, 'o' or 'O' ${eitherCaseOCount} times`) //Alternative way of joining values parts <- [i1, space, i2] sentence2 <- cat parts | collect as String //OR sentence3 <- cat [i1, space, i2] | collect as String stdout.println(`sentence2 and sentence3 are the same? ${sentence2 == sentence3}`) //Mechanism to extract parts of a String jumpingBrownFox <- cat sentence2 | skip 10 | head 15 | collect as String stdout.println(`jumpingBrownFox is "${jumpingBrownFox}"`) //Note that there is no issue with extending beyond the length of the sentence dog <- cat sentence2 | skip 40 | head 15 | collect as String stdout.println(`dog is "${dog}"`) //Even this is 'safe' and the result is just not set nonSuch <- cat sentence2 | skip 50 | head 5 | collect as String stdout.println(`nonSuch is set? ${nonSuch?}`)
It prints:
i3 is set? false Hashcode is -732416445 sentence1 is "The Quick Brown Fox Jumps Over The Lazy Dog" Length is 43 or 43 'o' appears 3 times, 'o' or 'O' 4 times sentence2 and sentence3 are the same? true jumpingBrownFox is "Brown Fox Jumps" dog is "Dog" nonSuch is set? false
The example shows:
- the standard operators, type inference, and length in its 'object form';
- two ways to join Strings: an interpolated String (`${i1} ${i2}`), and a List of String collected into one String with the '|' operator;
- the stream/pipeline commands map, skip and head to select Characters.
There is no index operator for the Characters of a String. Selection is by pipeline, or by position with subString(start, end) and indexOf, and none of them can run past the end: in the example, nonSuch is un set, and a subString out of range is un set too.
Most object-oriented languages keep adding methods to their types for jobs like this. EK9 puts the very frequent ones on the type (upper and lower case) and gives you the tools to write the rest as your own functions; see the design note on small type APIs.
Integer
An Integer is one of:
- Unknown not set
- A negative value down to -9,223,372,036,854,775,808
- A positive value up to 9,223,372,036,854,775,807
The comparison, mathematical , modification and ternary operators are covered on their own pages; here are a few examples. There is no plus or minus infinity (or NaN) in EK9: an infinity is not known, and that is un set.
//Some examples of using Integer i1 <- -9223372036854775808 hashCode <- #? i1 //results in -2147483648 asFloat <- #^ i1 //promotion to Float results in -9.223372036854776E18 //Division operations with zero nonResult <- zero / ninety //result is 0 (zero) nonResult := ninety / zero //result is un set i.e not known nonResult := zero / alsoZero //result is un set
Full source: builtInTypes_integer.ek9 - Download builtInTypes_integer.ek9
There are no unsigned, short or long variants. Integer is the 64-bit whole number; for whole numbers too large for it use BigInteger, and for a raw unsigned octet use Byte.
Pipeline with Integer result
Accumulating amounts is common, and many languages have a special syntax for it. EK9 has no list comprehensions; it has the more general streams/pipelines.
The example below makes a list of Integers from 1 up to and including 11 in steps of two, then totals them. A for loop could do it; here it is a pipeline. The key part is collect as Integer, and you can collect into your own types too.
theValues as List of Integer := List() //create a list to capture values in //Simple pipeline processing intSum <- for i in 1 ... 11 by 2 | tee in theValues | collect as Integer //theValues has the following contents [1, 3, 5, 7, 9, 11] //intSum has the value 36
Full source: builtInTypes_integer.ek9 - Download builtInTypes_integer.ek9
The pipeline approach gives reuse, and collection of objects as they are processed, in one standard syntax. The same pipeline works with Durations (covered later) instead of Integers: processing, accumulation, mapping and teeing are the same whatever the type. The syntax is admittedly not as terse as a list comprehension.
Float
A Float is one of:
- Unknown not set
- Negative values -4.9E-324 to -1.7976931348623157E308
- Positive values 4.9E-324 to 1.7976931348623157E308
As with Integer, the comparison, mathematical , modification and ternary operators are covered on their own pages.
//Some examples of using Float i1 <- -4.9E-324 hashCode <- #? i1 //results in -2147483647 //Division operations with zero nonResult <- zero / ninety //result is 0.0 (zero) nonResult := ninety / zero //result is un set i.e not known nonResult := zero / alsoZero //result is un set
Full source: builtInTypes_integer.ek9 - Download builtInTypes_integer.ek9
Float has most of Integer's operators, except promotion '#^', and its precision varies with the magnitude. It works in pipelines too.
It also has trigonometric and transcendental methods, in radians, from the platform maths library: sin, cos, tan, asin, acos, atan, atan2, log (natural), log10 and exp, plus round(places). A result that is not a finite number (for example asin of a value outside -1..1) is un set. The constants pi and e are on Maths.
BigInteger
BigInteger is an arbitrary-precision integer. Integer is 64-bit and becomes un set on overflow; a BigInteger grows as large as needed, so '+', '-', '*' and '^' (power) never overflow. Use it for big factorials, unbounded Fibonacci or large combinatorics.
It has the same comparison and mathematical operators as Integer, plus sqrt and abs. mod and rem return an Integer (as EK9 requires); a result that will not fit in 64 bits is un set rather than silently losing data. There is deliberately no promotion ('#^'): a BigInteger is wider than a Float, so an implicit conversion would lose precision.
#!ek9 defines module site.builtInTypes.bigInteger defines program BigIntegerExample() stdout <- Stdout() //A value far beyond the range of a 64-bit Integer big <- BigInteger("123456789012345678901234567890") //Multiplication never overflows squared <- big * big stdout.println("Squared is " + $squared)
BigDecimal
BigDecimal is an arbitrary-precision decimal, the exact counterpart of Float. Float is 64-bit binary and cannot represent values such as 0.1 exactly; a BigDecimal can, so 0.1 + 0.2 really is 0.3. Use it for exact or high-precision calculation (for money, use Money).
- Equality is by value, not scale: 1.0 equals 1.00 ('==', '<=>' and '#?' all compare value).
- Division and sqrt keep 34 significant digits, so a non-terminating result such as 1/3 is well defined; division by zero is un set.
- It has round(places), abs and '^' (power). Like BigInteger it has no promotion operator.
#!ek9 defines module site.builtInTypes.bigDecimal defines program BigDecimalExample() stdout <- Stdout() total <- BigDecimal("0.1") + BigDecimal("0.2") stdout.println("0.1 + 0.2 = " + $total)
Maths
Maths is a small stateless type holding the mathematical constants as Float values, pi() and e(). Create one with Maths() and read them; the trigonometric and transcendental functions are on Float.
#!ek9 defines module site.builtInTypes.maths defines program MathsExample() stdout <- Stdout() maths <- Maths() //sin of pi/2 is 1.0 halfPi <- maths.pi() / 2.0 stdout.println("sin(pi/2) = " + $halfPi.sin())
Bits
Bits is one of:
- Unknown not set
- Any number of bits
The bitwise operators are in the operators section. '+' and '+=' join Bits together, as does '|' in a stream/pipeline.
//Bits values a <- 0b010011 b <- 0b101010 c <- 0b010011 //Equality Operators equalAB <- a == b //result would be false notEqualAB <- a <> b //result would be true lessAB <- a < b //result would be true lessOrEqualAB <- a <= b //result would be true greaterAB <- a > b //result would be false greaterOrEqualAB <- a >= b //result would be false equalAC <- a == c //result would be true notEqualAC <- a != c //result would be false alsoNotEqualAC <- a <> c //result would be false (alternate syntax) //Joining/Adding Bits set6 <- 0b010011 set7 <- 0b101010 set6set7 <- set6 + set7 //result would be 010011101010 set6true <- set6 + true //result would be 0100111 set6false <- set6 + false //result would be 0100110 set6false += true //set6false would now be 01001101 //Bitwise operations ored <- set6 or set7 //result would be 0b111011 xored <- set6 xor set7 //result would be 0b111001 anded <- set6 and set7 //result would be 0b000010 notted <- ~set6 //result would be 0b101100 alsonotted <- not set6 //result would be 0b101100
Full source: builtInTypes_bits.ek9 - Download builtInTypes_bits.ek9
As the addition shows, Bits are not numbers.
To take a range of bits, use a stream/pipeline:
//Extracting ranges of Bits //assumes function booleanToBits has been defined partial <- cat set6false | skip 3 | map with booleanToBits | collect as Bits //partial has the value of 0b10010
Full source: builtInTypes_bits.ek9 - Download builtInTypes_bits.ek9
Bits read right to left: the least significant bit is on the right and the most significant on the left, so streaming the bits (as Boolean values) starts with the least significant. In the example the "101" is skipped, leaving "01001". The bits arrive least significant first and each one collected is added on the right (as + adds a bit), so the result reads 0b10010, the remaining bits in reverse. Use head and tail to narrow the selection further.
The same functional approach selects from any sequence, so it is repeatable and consistent rather than a set of special methods per class. The design note on small type APIs explains the thinking.
Byte
Byte is an unsigned 8-bit value, 0 to 255, the scalar of the byte family. It gives raw binary data an element type with real EK9 semantics (value semantics, immutability and tri-state) rather than a naked small integer.
- Unsigned, with no arithmetic. Raw binary data is the use case, not arithmetic, so there is no '+', '-', '*', '/', '++' or '--': "what is 0xFF + 1?" cannot be asked of a Byte. A value outside the range constructs un set, not wrapped, so a bad octet is visibly unset instead of quietly wrong.
- Arithmetic widens. Convert with toInteger(), or let '#^' promote it inside an Integer expression. Note the asymmetry: 1 + aByte resolves, because promotion applies to the argument of Integer's '+'; aByte + 1 does not, because Byte has no '+'. Arithmetic over a Byte always widens to Integer and never wraps.
- Hex in both directions. '$' gives exactly two upper-case hex characters, and the String constructor parses hex with an optional 0x prefix, so Byte($b) == b for all 256 values. Decimal text is rejected - Byte("255") is un set - because "10" cannot mean sixteen to '$' and ten to the constructor. Decimal comes in through Byte(Integer).
- Bitwise. 'and', 'or', 'xor' and '~' work as on Bits and Boolean. '<<' and '>>' stay inside eight bits (bits shifted out are lost and the value never widens), and '>>' is always a logical shift: there is no sign bit.
#!ek9 defines module site.builtInTypes.byte defines program ByteExample() stdout <- Stdout() //Hex text, an Integer, or a copy fromHex <- Byte("0xFF") fromInteger <- Byte(255) //$ always emits two upper-case hex characters, so the round trip holds stdout.println($fromHex) //FF stdout.println($Byte(15)) //0F //Out of range is un set, never wrapped stdout.println(`Byte(256) is set: ${Byte(256)?}`) //Bitwise, and shifts that stay inside eight bits masked <- Byte("AA") and Byte("0F") //0A stdout.println($masked) stdout.println($(Byte("0F") << 4)) //F0 stdout.println($(Byte("0F") << 8)) //00 - shifted out, not widened //Arithmetic is deliberate - and widens rather than wrapping stdout.println(`1 + 0xFF = ${1 + #^fromInteger}`) //256 //Conversions stdout.println($fromInteger.toInteger()) //255 stdout.println($Byte("AA").toBits()) //10101010
Bytes
Bytes is an immutable sequence of unsigned 8-bit values, the public face of binary data. It follows the sequence semantics of Bits, not a number's: '+' concatenates, '|' accumulates, and there is no arithmetic.
- 🔑 The literal is 0y followed by hex digits in PAIRS: 0yDEADBEEF. 0x stays an Integer literal and 0b a Bits literal, so the three never collide. The lexer enforces the even-digit rule, so 0yF does not parse at all rather than becoming an un set Bytes to be diagnosed later: half a byte is not a byte, and the earliest place to say so is the one that cannot be bypassed. 0Y works too, as 0X/0B do.
- Empty is not un set. Bytes() is un set; a zero-length Bytes is set and empty. Binary protocols depend on the difference: a field that was absent and a field that was present and empty are not the same fact.
- Hex both ways, as on Byte: '$' gives upper-case hex and the String constructor parses hex, so Bytes($b) == b always holds. An odd number of hex digits is un set. Base64 is deliberately not guessed, because "DEADBEEF" is valid hex and valid Base64 at once; name the encoding with the two-argument constructor, Bytes(text, "base64").
- Buffers come from the withLength factories (the 'with' convention of the standard library): Bytes().withLength(16) is sixteen zero bytes, and Bytes().withLength(16, Byte("FF")) fills them with a value.
- 🔑 'and', 'or' and 'xor' need both operands the same length. A mismatch is un set, never zero-extended. This is the one place Bytes departs from Bits, and the reason is a bug category, not a preference: zero-extending a short key leaves the tail of the longer operand unchanged, so the "ciphertext" would carry plaintext while the operation reported success. A 16-byte key is 16 bytes, so a mismatch is a defect at the call site and is made visible.
- Shifts. '<<' and '>>' shift the whole sequence by a number of bits, carrying them across byte boundaries, and keep the length: bits shifted off the end are lost, as on Byte. That is deliberately not the Bits behaviour, where a left shift lengthens the sequence. To change width, say so: growLeft(count) and growRight(count), each with an optional fill Byte, add room at one end. Bytes("DEAD").growLeft(2) is 0000DEAD, and a following '<< 16' moves the content into the new room.
- Ordering ('<', '<=', '>', '>=', '<=>') is lexicographic over unsigned octets, so 0xFF sorts above 0x7F.
- slice, first and last return values, not views, so a slice can never change its source; an out-of-range request is un set, not clamped. indexOf is un set when the needle is absent: there is no -1 to mistake for an index.
- Use constantTimeEquals for MACs, tokens and digests: it examines every byte wherever the first difference is, whereas '==' may stop early.
#!ek9 defines module site.builtInTypes.bytes defines program BytesExample() stdout <- Stdout() //Hex in, hex out - the round trip always holds payload <- Bytes("DEADBEEF") stdout.println(`${payload} has ${length payload} bytes`) stdout.println(payload.toBase64()) //Buffers via the 'with' factory convention mask <- Bytes().withLength(4, Byte("FF")) //xor is its own inverse - the workhorse of low level code masked <- payload xor mask stdout.println(`masked ${masked}, recovered ${(masked xor mask) == payload}`) //Unequal lengths are un set, never zero extended stdout.println(`short key set: ${(payload xor Bytes("FF"))?}`) //'+' concatenates and slices are values, not views stdout.println($(Bytes("DEAD") + Bytes("BEEF"))) stdout.println($payload.first(2)) //Shifts cross byte boundaries and keep the length stdout.println($(payload << 4)) //Grow to make room, then shift the content into it stdout.println($(Bytes("DEAD").growLeft(2) << 16))
BytesReader
BytesReader is a forward-only cursor over a Bytes, for parsing binary protocols and formats. Get one with aBytes.reader() or BytesReader(aBytes). A reader constructed with no source is unset and has nothing to read.
- 🔑 Malformed input is a value, not an exception. Every read is tri-state. A read that would run past the end returns un set and leaves the cursor where it was. So parsing is a sequence of ordinary reads followed by a set-check, with no exception handling and no bounds test before each field. A smaller read still succeeds after a failed one, and a wide read can never part-consume.
- Cursor state is position(), remaining() and atEnd(); peek() looks at the next byte without consuming it. skip(count) advances, or answers false and does not move if it would run past the end; it never clamps.
- 🔑 Endianness is always explicit. There is no readUInt32() with a default, only readUInt16BE/LE, readUInt32BE/LE, readUInt64BE/LE and the signed readInt16/32/64BE/LE. Signed reads sign-extend; unsigned ones do not.
- ⚠️ readUInt64BE/LE is un set when the top bit is set. EK9's Integer is 64-bit signed, so an unsigned 64-bit value that large has no correct representation; returning it would give a negative number for a large positive one. It is also not consumed, so readInt64BE can reinterpret the same bytes.
- slice(count) makes nested formats safe: a bounded sub-reader over the next n bytes, which it consumes from the parent. The sub-reader cannot read past its own boundary however malformed its content, and the parent carries on after it, which is what a length-prefixed structure needs.
Why EK9 does it this way. A silent endianness default is a known bug category: it is right on the machine it was written on and wrong in production. EK9 removes bug categories by making them unrepresentable, so the defaulted spelling does not exist.
#!ek9 defines module site.builtInTypes.bytesReader defines program BytesReaderExample() stdout <- Stdout() //A TLS-record shape: type, version, body length, then a length-prefixed body record <- Bytes("1603030008034142430358595a") cursor <- record.reader() stdout.println(`type ${cursor.readByte()}, version ${cursor.readUInt16BE()}`) bodyLength <- cursor.readUInt16BE() //A bounded view - it cannot read past its own boundary body <- cursor.slice(bodyLength) fieldLength <- body.readByte().toInteger() stdout.println(`first field: ${body.readBytes(fieldLength)}`) //A short read is un set and does NOT move the cursor short <- Bytes("010203").reader() stdout.println(`wide read set: ${short.readUInt32BE()?}`) stdout.println(`still at ${short.position()}, smaller read gives ${short.readUInt16BE()}`)
BytesBuilder
BytesBuilder is the mutable working buffer, the write side of the family and the counterpart of BytesReader. It is the only type here that allows an indexed byte write, and it exists so that capability does not leak into Bytes or general application code.
🔑 Speed is not why it exists. Bytes is a value, so every + and | copies the whole accumulator and building byte by byte is quadratic; but that is correct, only slow, and fine at protocol sizes. Three capabilities cannot be expressed on Bytes at any speed, and they are the reason:
- Endian-aware writes. appendUInt16BE/LE, appendUInt32BE/LE, appendUInt64BE/LE and the signed appendInt16/32/64BE/LE: twelve writes matching BytesReader's twelve reads one for one. Without them there is no endian-aware write anywhere in the language, so callers would hand-assemble bytes and bring back the endianness bug the reader removed. As on the read side, there is no defaulted appendUInt32().
- In-place mutation: setByte(index, aByte), xorInto(offset, source) and copyInto(offset, source). A value type cannot offer these, because every operation on Bytes yields a new array. xorInto is one primitive with three uses: AES AddRoundKey, the Argon2 block XOR and the ChaCha20 keystream. All three write within the current length and never extend; use append to grow.
- wipe(). A Bytes cannot be securely erased, because + and | have already scattered copies of it. A builder is one buffer that can be zeroed, which key material and password buffers need. It is best effort: the JVM may have moved the buffer during garbage collection and the OS may have paged it out.
- ⚠️ Range is enforced, not truncated. appendUInt16BE(0x10000) makes the builder un set rather than writing 0x0000, and appendUInt64BE(-1) is refused rather than written as two's complement, the mirror image of readUInt64BE refusing to hand back a negative for a large positive. Use the signed spelling for a signed value. A write that would run past the end is refused outright, never part-applied: half an applied round key is worse than none.
- An empty builder is set. capacity() is backing storage, not length; reserve(n) grows it and never shrinks it. Growth is geometric, so appending is amortised, not quadratic. toBytes() is the single boundary back to an immutable value and always copies, so a snapshot cannot change under whoever holds it.
- ⚠️ $ reports shape, never content: BytesBuilder(4/16), the length and capacity, so a buffer holding a key cannot leak through a debug print. For the same reason there is no #?: a hash that changes as the buffer grows makes a mutable key look usable, and that is a bug, not a feature.
#!ek9 defines module site.builtInTypes.bytesBuilder defines constant tlsVersionTls12 <- 0x0303 tlsBodyLength <- 8 defines program BytesBuilderExample() stdout <- Stdout() //Building the TLS-record shape that the BytesReader example parses. The endianness is in //the method name, so it is visible on the line doing the writing. record <- BytesBuilder() record.append(Byte("16")) record.appendUInt16BE(tlsVersionTls12) record.appendUInt16BE(tlsBodyLength) record.append(Bytes("034142430358595a")) stdout.println(`framed: ${record.toBytes()}`) //Big and little endian genuinely differ - there is no defaulted spelling bigEndian <- BytesBuilder() bigEndian.appendUInt32BE(0x01020304) littleEndian <- BytesBuilder() littleEndian.appendUInt32LE(0x01020304) stdout.println(`big ${bigEndian.toBytes()}, little ${littleEndian.toBytes()}`) //A number too wide for its field is rejected, never truncated into a quiet lie tooWide <- BytesBuilder() tooWide.appendUInt16BE(0x10000) stdout.println(`too wide accepted: ${tooWide?}`) //xorInto is AddRoundKey, the Argon2 block XOR and the ChaCha20 keystream application, //and it is its own inverse block <- BytesBuilder(Bytes("ffffffff")) block.xorInto(0, Bytes("0f0f0f0f")) stdout.println(`after xor: ${block.toBytes()}`) //wipe zeroes the backing store and leaves the builder usable - something an immutable //Bytes fundamentally cannot offer secret <- BytesBuilder(Bytes("cafebabedeadbeef")) secret.wipe() stdout.println(`wiped to length ${length secret}, still usable ${secret?}`)
Digest
Digest produces unkeyed cryptographic hashes. It answers one question: has this content changed? That is all this capability has ever been, and Digest is its honest name. A digest proves integrity and nothing else: anyone can compute one, so anyone who can replace your content can replace the digest beside it too.
- SHA256 has three overloads. SHA256(String) and SHA256(GUID) return a hexadecimal String; SHA256(Bytes) takes and returns Bytes. Use the last when the input is a file, a request body or anything else that is not text: it hashes the octets with no decoding and no encoding assumption, and chains straight into the next step (an HMAC key, a signature) without a hex round trip.
- SHA512 has the same three overloads, so the two are learned as one form; SHA512(Bytes) returns 64 octets where SHA-256 returns 32. SHA-1 and MD5 are deliberately not offered: both are broken for security use.
- 🔑 Hex is UPPER-case across the whole family. SHA256(String), Bytes' $ and toText("hex") render the same way, so a String digest and a Bytes digest of the same input are equal with no normalising. (Before 2026-09-25 the String form was lower-case, inherited from the type then misnamed HMAC, and the two spellings of one SHA-256 were silently unequal.)
- ⚠️ Published vectors are usually written lower-case (shasum, git and openssl print that way), so fold the case of a literal copied from a specification before comparing. The value is the same; only the rendering differs.
🔑 To prove WHO produced something, this is the wrong type: use HMAC. A hash over an attacker's content is exactly as valid as a hash over yours, and that mistake is what splitting the two types prevents.
HMAC
HMAC is a keyed message authentication code. It proves integrity AND authenticity, because producing one needs the key: SHA256(key, message), where both are Bytes and so is the result.
- ⚠️ Verify a received tag with constantTimeEquals, never with ==. Both answer the same question, but == may stop at the first differing byte, and the time it took tells an attacker how many leading bytes they guessed, which forges a tag one byte at a time. Bytes.constantTimeEquals folds in the XOR at every position, and the length difference too, so there is nothing to time.
- Unset in, unset out: an unset key or message gives an unset result, never a MAC over a substitute value. An empty key is not an unset one and is accepted, because empty is set.
Why EK9 does it this way. The key is not optional, and that is the point. Until the Bytes family existed there was no honest way to express a keyed MAC, and this type carried the unkeyed hashes instead, so code reaching for HMAC got a bare digest and the authenticity it assumed was imaginary. Those hashes now live on Digest, and this type means what its name says.
The example uses RFC 4231 test case 2, a published vector: it agrees with every conforming implementation or it is wrong. A value captured from our own output would pass equally well over a subtly broken MAC.
#!ek9 defines module site.builtInTypes.digestHmac defines constant sha256HexLength <- 64 defines program DigestAndHmacExample() stdout <- Stdout() //Digest is UNKEYED - anyone can compute it, so it proves only integrity fingerprint <- Digest().SHA256("the quick brown fox") stdout.println(`fingerprint length ${length fingerprint}, expected ${sha256HexLength}`) //The Bytes overload hashes OCTETS and answers in octets - no encoding assumption //anywhere, so it works on a binary payload as readily as on text payload <- Bytes().withUtf8("the quick brown fox") asOctets <- Digest().SHA256(payload) stdout.println(`same value both ways: ${asOctets.toText("hex") == fingerprint}`) //HMAC is KEYED - producing one requires the secret, so it proves WHO key <- Bytes().withUtf8("Jefe") message <- Bytes().withUtf8("what do ya want for nothing?") mac <- HMAC().SHA256(key, message) stdout.println(`mac: ${mac}`) //🔑 Change the key and the MAC changes completely. That is the whole difference: //a Digest anyone can forge over new content, an HMAC they cannot without the key. forged <- HMAC().SHA256(Bytes().withUtf8("wrong-key"), message) stdout.println(`a wrong key cannot reproduce it: ${mac <> forged}`) //⚠️ VERIFY WITH constantTimeEquals, never with ==. Both answer the same question, but //== is free to stop at the first differing byte, and how long it took then tells an //attacker how many leading bytes they guessed right - a tag forged one byte at a time. stdout.println(`verified: ${mac.constantTimeEquals(HMAC().SHA256(key, message))}`) stdout.println(`forgery rejected: ${mac.constantTimeEquals(forged)}`) //Unset in, unset out - never a MAC computed over a substitute value stdout.println(`unset key gives unset mac: ${HMAC().SHA256(Bytes(), message)?}`)
It prints:
fingerprint length 64, expected 64 same value both ways: true mac: 5BDCC146BF60754E6A042426089575C75A003F089D2739839DEC58B964EC3843 a wrong key cannot reproduce it: true verified: true forgery rejected: false unset key gives unset mac: false
SecureRandom
SecureRandom produces cryptographically secure random octets, for nonces, salts, keys and tokens. It has one operation, nextBytes(count), which returns count octets as Bytes. A token is those octets as text: nextBytes(32).toBase64() or .toText("hex"). There is deliberately no next() returning an Integer: reducing random numbers into a range by hand is how modulo bias gets into tokens.
Tri-state: an unset or negative count gives unset Bytes, never a substitute length. A count of zero gives empty Bytes, which are set: asking for nothing and getting nothing is a well-defined answer.
Why EK9 does it this way. 🔑 SecureRandom is deliberately not a Random. SystemRandom and SeededRandom implement the Random trait; SecureRandom does not. If it did, anything accepting a Random would accept a SeededRandom too, and a seeded generator gives the same sequence every run, so a nonce or salt made from it is reproducible by anyone who knows the seed. Standing outside the trait, a function that needs unpredictable octets declares a SecureRandom parameter, and passing it a SeededRandom is a compile error rather than a code-review catch. For the same reason there is no seeded constructor.
#!ek9 defines module site.builtInTypes.secureRandom defines function //Declaring SecureRandom is the safety property - a SeededRandom will not compile here newSalt() as pure -> generator as SecureRandom <- rtn as Bytes: generator.nextBytes(16) defines program SecureRandomExample() stdout <- Stdout() generator <- SecureRandom() //Exactly the number of octets asked for salt <- newSalt(generator) stdout.println(`salt length: ${length salt}`) //A token is octets rendered as text - there is no next() Integer on purpose token <- generator.nextBytes(32).toBase64() stdout.println(`token length: ${length token}`) //Two generators never agree - there is no seed for them to share stdout.println(`generators differ: ${SecureRandom().nextBytes(32) <> SecureRandom().nextBytes(32)}`) //Zero is EMPTY and SET; an unset or negative count is UNSET stdout.println(`empty is set: ${generator.nextBytes(0)?}`) stdout.println(`negative count: ${generator.nextBytes(-1)?}`)
It prints:
salt length: 16 token length: 44 generators differ: true empty is set: true negative count: false
Time
A Time is one of:
- Unknown not set
- Any valid time of day
Time is a time of day, HH:MM or HH:MM:SS, with no date. A time of day on a particular date in a particular time zone is a DateTime.
The comparison and ternary operators are covered on their own pages. Time works with Duration:
- '+', '-', '+=' and '-=' take a Duration.
- Subtracting one Time from another gives a Duration.
- Adding, multiplying or dividing by a Time makes no sense, so it is not offered (with a Duration it does make sense).
The example also shows the require keyword, for pre-conditions and post-conditions.
#!ek9 defines module site.builtInTypes.time defines program ShowTimeType() //These declarations and operations should be obvious by now if you've read the other sections. t1 <- 12:00 t2 <- 12:00:01 t3 <- 12:00:01 t4 <- Time() t5 <- Time(12, 00) t6 <- Time(12, 00, 01) require t1 <> t2 require t2 == t3 require t1 < t2 require t1 <= t2 require t2 <= t3 require t2 > t1 require t2 >= t1 require t3 >= t2 //Check not set require ~t4? require t1 == t5 require t2 == t6 //Just create a Time object to get the current time t4a <- SystemClock().time() //t4a will be set to current time t4b <- t4.startOfDay() //t4b will be set to 00:00:00 t4c <- t4.endOfDay() //t4c will be set to 23:59:59 require t4a? Stdout().println(`Start of day ${t4b}, end of day ${t4c}`) //Now actually alter t4 itself t4.set(SystemClock().time()) //t4 will now be set to the current time require t4? t4.setStartOfDay() //Will be set to 00:00:00 require t4? t4.setEndOfDay() //Will be set to 23:59:59 require t4? //Durations d1 <- PT1H2M3S //one hour, two minutes and three seconds d2 <- P3DT2H59M18S //three days, two hours, fifty nine minutes and eighteen seconds //Note how only the hours minutes and seconds of 'd2' is used. //Addition operations t4 := t5 + d1 require t4 == 13:02:03 t4 += d2 require t4 == 16:01:21 //Subtraction operations t4 := t5 - d1 require t4 == 10:57:57 t4 -= d2 require t4 == 07:58:39 //Get the Duration between two times d3 <- 12:04:09 - 06:15:12 require d3 == PT5H48M57S //Note the different views of a Duration d4 <- 06:15:12 - 12:04:09 require d4 == PT-6H11M3S require d4 == PT-5H-48M-57S //Example of building a time from a number of durations durations as List of Duration := [d1, d2] t7 <- cat durations | collect as Time require t7 == 04:01:21 //t7 will default to start of the day plus each of the durations piped in //Note that time will just roll over just after 23:59:59 //It is also possible to get the hour, minute and second from a Time second <- t7.second() minute <- t7.minute() hour <- t7.hour() require 4 == hour require 1 == minute require 21 == second
Duration
A Duration is one of:
- Unknown not set
- Any positive duration from 1 second to years
- Any negative duration from 1 second to years
With Time only hours, minutes and seconds matter, but a Duration can also hold years, months and days, which matters for Date and DateTime.
The format is ISO 8601 Durations, P[n]Y[n]M[n]W[n]DT[n]H[n]M[n]S, without fractional values (see Millisecond for fractions of a second). 'P' marks a period, and each '[n]' is followed by its field: "P3Y6M4DT12H30M5S" is three years, six months, four days, twelve hours, thirty minutes and five seconds. 'W' gives weeks.
To a person, anything under a second is a different category of time, so Duration has no milliseconds. Milliseconds can still add up to something significant, so Millisecond and Duration are separate types designed to work together.
//Some examples of using Duration d1 <- PT1H2M3S //results in 1 hour, 2 minutes and 3 seconds d2 <- P3DT2H59M18S //results in 3 days, 2 hours, 59 minutes and 18 seconds d3 <- P2Y6W3DT8H5M8S //results in 2 years, 45 days, 8 hours, 5 minutes and 8 seconds //Note that 6 weeks was converted to 42 days and then the additional 3 days are added to that d4 <- P2Y2M1W3DT8H5M8S //results in 2 years, 2 months, 10 days, 8 hours, 5 minutes and 8 seconds d5 <- P2W //results in 14 days //Mathematics with durations d6 <- PT1H2M1S twice <- d6 * 2 //results in PT2H4M2S i.e. twice the duration in d6 scaled <- d6 * 8.25 //results in PT8H31M38S divided <- scaled / 3 //results in PT2H50M32S extended <- d6 + PT20M //results in PT1H22M1S reduced <- d6 - PT2M6S //results in PT59M55S reduced += PT2H5S //results in PT3H
Full source: builtInTypes_duration.ek9 - Download builtInTypes_duration.ek9
A Duration takes ordinary arithmetic, but only where it makes sense: you can add and subtract Durations, and multiply or divide one by an Integer or Float, but not add an Integer, Float or String to it. Operators overloaded with suitable types like these, on the built-in types and on your own records and classes, are what keep EK9 readable.
Millisecond
A Millisecond is one of:
- Unknown not set
- Any valid Integer value
The literal is digits then 'ms': 250ms is two hundred and fifty milliseconds. The compiler checks millisecond literals. Any integer is valid, positive or negative, so 6000ms is 6 seconds.
- duration() converts to a Duration, rounded to the nearest second, with a half rounded to the even second: 1400ms is one second, 1500ms, 1501ms and 2500ms are two.
- Add a Millisecond to a Duration and it is rounded to seconds the same way before it is added: P1D + 1500ms is P1DT2S.
- It has the usual mathematical and comparison operators, promotes to a Duration, and can be constructed from one.
Date
A Date is one of:
- Unknown not set
- Any valid date
The literal is YYYY-MM-DD: 2020-11-18 is the eighteenth of November 2020. The compiler checks date literals, so '2020-11-31' does not compile (E07900).
A Date has no time zone, so it suits a date of birth, for example. To be exact about an instant, use a DateTime.
Its operations match Time's: comparison, ternary, and arithmetic with a Duration ('+', '-', '+=' and '-=' take a Duration; one Date minus another gives a Duration; adding, multiplying or dividing by a Date is not offered).
#!ek9 defines module site.builtInTypes.date defines program ShowDateType() //3rd of October 2020 date1 <- 2020-10-03 date2 <- 2020-10-04 date3 <- 2020-10-04 date4 <- Date() date5 <- Date(2020, 10, 03) date6 <- Date(2020, 10, 04) require date1 <> date2 require date2 == date3 require date1 < date2 require date1 <= date2 require date2 <= date3 require date2 > date1 require date2 >= date1 require date3 >= date2 require ~date4? require date1 == date5 require date2 == date6 date4a <- Date().today() require date4a? date4.setToday() //Now set to the current date //Durations d1 <- P1Y1M4D //one year, one month and four days d2 <- P4W1DT2H59M18S //twenty nine days, two hours, fifty nine minutes and eighteen seconds //Note how only the days part of 'd2' is used date4 := date5 + d1 require date4 == 2021-11-07 date4 += d2 require date4 == 2021-12-06 d3 <- date4 - date5 require d3 == P1Y2M3D //Pipeline with durations and dates. durations as List of Duration := [d1, d2] //Because the epoch is 1970-01-01 accumulation starts from that date date7 <- cat durations | collect as Date require date7 == 1971-03-06 //If you want to accumulate from a specific date you can do this instead date8 <- 0000-01-01 cat durations >> date8 require date8 == 0001-03-06 dayOfMonth <- date8.day() monthOfYear <- date8.month() year <- date8.year() dayOfTheWeek <- date8.dayOfWeek() require dayOfMonth == 6 require monthOfYear == 3 require year == 1 require dayOfTheWeek == 2
Take care when adding durations to points in time and subtracting points in time to get durations back; there are some strange effects:
- Adding a duration of a few months and days across a leap year gives a date that takes that leap year into account.
- Adding durations together counts 30 days per month when it sums days (P20D + P20D is P1M10D). So summing several durations and applying the total to a date gives one result, and applying each duration in turn gives another.
To get a final date from a base date and several durations, apply each duration in turn to the base date rather than summing them first:
//Adding Durations to a Base Date date <- 2019-06-01 //Definition of durations omitted cat durations >> date //The Easiest way to move a date on is by a number of durations
Full source: builtInTypes_duration.ek9 - Download builtInTypes_duration.ek9
That way any number of positive, negative or even empty durations can be applied, and the date moves by the right amount from the base date it started at, including leap years and the varying days per month.
DateTime
DateTime is an aggregate of sorts: it holds an instant in time together with a time zone, so it relates a Date, a Time and a time zone in one type.
A DateTime is one of:
- Unknown not set
- Any valid date/time within a time zone
The literal is YYYY-MM-DDTHH:MM:SS+/-HH:MM: 2020-11-18T13:45:21-01:00 is the eighteenth of November 2020 at thirteen forty-five and twenty-one seconds, in a time zone one hour behind UTC. 'Z' (Zulu, GMT/UTC) is short for +/-00:00: 2020-11-18T14:45:21Z.
Time zones take a little more thought than Time and Date on their own. Is 2020-11-18T13:45:21-01:00 less than or greater than 2020-11-18T13:45:21Z? The "-01:00" may mislead you: it is greater. Adjust both to the same time zone (add the hour to the time of day) and it becomes obvious.
The operations match Date and Time: comparison, ternary, and calculation with a Duration.
The example uses time zones. Arithmetic over dates, times and time zones can be confusing; the thing to keep in mind is the same instant in time: if you are in London, what time (and date) is it right now in New York?
#!ek9 defines module site.builtInTypes.dateTime defines program ShowDateTimeType() //3rd of october 2020 @ 12:00 UTC dateTime1 <- 2020-10-03T12:00:00Z dateTime2 <- 2020-10-04T12:15:00-05:00 dateTime3 <- 2020-10-04T12:15:00-05:00 dateTime4 <- DateTime() dateTime5 <- DateTime(year: 2020, month: 10, dayOfMonth: 3, hour: 12) dateTime6 <- DateTime(year: 2020, month: 10, dayOfMonth: 4, hour: 12, minute: 15) require dateTime1 <> dateTime2 require dateTime2 == dateTime3 require dateTime1 < dateTime2 require dateTime1 <= dateTime2 require dateTime2 <= dateTime3 require dateTime2 > dateTime1 require dateTime2 >= dateTime1 require dateTime3 >= dateTime2 require ~dateTime4? require dateTime1 == dateTime5 dateTime4a <- DateTime().today() require dateTime4a? dateTime4.setToday() //Now set to the current dateTime //Durations d1 <- P1Y1M4D //one year, one month and 4 days d2 <- P4W1DT2H59M18S //twenty nine days, two hours, fifty nine minutes and eighteen seconds dateTime4 := dateTime5 + d1 require dateTime4 == 2021-11-07T12:00:00Z dateTime4 += d2 require dateTime4 == 2021-12-06T14:59:18Z d3 <- dateTime4 - dateTime5 require d3 == P1Y2M3DT2H59M18S d4 <- dateTime2 - dateTime3 require d4 == PT0S //Think carefully about this (focus on the instant) //At ZULU/GMT/UTC what time will it be in the -05:00 time zone? //2020-10-04T12:15:00-05:00 //2020-10-04T12:15:00Z d5 <- dateTime3 - dateTime6 require d5 == PT5H d6 <- dateTime3.offSetFromUTC() require d6 == PT-5H //processing of durations durations as List of Duration := [d1, d2] dateTime7 <- cat durations | collect as DateTime //Because the epoch is 1970-01-01 require dateTime7 == 1971-03-06T02:59:18Z //From a specific date time add on the durations in turn dateTime8 <- 0000-01-01T00:00:00Z cat durations >> dateTime8 require dateTime8 == 0001-03-06T02:59:18Z dayOfMonth <- dateTime8.day() monthOfYear <- dateTime8.month() year <- dateTime8.year() dayOfTheWeek <- dateTime8.dayOfWeek() hourOfTheDay <- dateTime8.hour() minuteOfTheHour <- dateTime8.minute() secondOfTheMinute <- dateTime8.second() timeZone <- dateTime8.zone() offset <- dateTime8.offSetFromUTC() require dayOfMonth == 6 require monthOfYear == 3 require year == 1 require dayOfTheWeek == 2 require hourOfTheDay == 2 require minuteOfTheHour == 59 require secondOfTheMinute == 18 require timeZone == "Z" require offset == PT0S
The example is long, but the ideas are those of Date and Time with Duration: ordinary mathematical operators with the same semantics whatever the type. That keeps down the number of method names, helper classes and third-party APIs you need.
Why EK9 does it this way. If you adopt EK9, go with the grain and flow of it. Don't fight it; if you find yourself doing so, a different language such as C or C# would suit you better. The design note on small type APIs sets out the reasoning.
Money
Money models a modern business concept, and many programming languages have no built-in type for it. EK9 is aimed at a wide range of uses, so it includes Money from the start. Money is harder to work with than a DateTime: the number of fractional parts (cents and so on) and the rounding vary from currency to currency.
A Money is one of:
- Unknown not set
- Any valid positive decimal value with a currency
- Any valid negative decimal value with a currency
The literal is D+(.D+)?#SSS: digits, an optional point and fractional part, then '#' and the three-letter ISO 4217 currency code.
The number of fractional places depends on the currency. The Chilean Unidad de Fomento has four (45.9999#CLF), the Iraqi Dinar three (45.000#IQD), GBP, USD and many others two, and the Guinea Franc none.
That literal format is for code; end users see Money through a Locale. How you present it is your choice: a currency may have two decimal places, but a fixed charge of £50 or $75 in your application may not need its pennies or cents shown.
Examples of Money:
#!ek9 defines module site.builtInTypes.money defines program ShowMoneyType() tenPounds <- 10#GBP require tenPounds == 10.00#GBP thirtyDollarsTwentyCents <- 30.2#USD require thirtyDollarsTwentyCents == 30.20#USD //Mathematical operators nintyNinePoundsFiftyOnePence <- tenPounds + 89.51#GBP require nintyNinePoundsFiftyOnePence == 99.51#GBP require tenPounds < nintyNinePoundsFiftyOnePence require nintyNinePoundsFiftyOnePence > tenPounds require tenPounds <> nintyNinePoundsFiftyOnePence require tenPounds <= 10.00#GBP require tenPounds >= 10.00#GBP //As they are different currencies - operation like this are meaningless //You will have to convert to the same currency to compare or combine them. require ~(tenPounds != thirtyDollarsTwentyCents)? require ~(tenPounds == thirtyDollarsTwentyCents)? //The above requirements are checking if the equality test is a valid test, not the result of the equality test. //rounding up for money values 49.755 is 49.76 fourtyNinePoundsSeventySixPence <- nintyNinePoundsFiftyOnePence/2 require fourtyNinePoundsSeventySixPence == 49.76#GBP minusFourHundredAndThirtyFivePoundsSixtyPence <- fourtyNinePoundsSeventySixPence * -8.754 require minusFourHundredAndThirtyFivePoundsSixtyPence == -435.60#GBP nineHundredAndThirtyFivePoundsSixtyPence <- 500#GBP - minusFourHundredAndThirtyFivePoundsSixtyPence require nineHundredAndThirtyFivePoundsSixtyPence == 935.60#GBP variableAmount <- (nineHundredAndThirtyFivePoundsSixtyPence * 3) / 18 require variableAmount == 155.93#GBP multiplier <- nineHundredAndThirtyFivePoundsSixtyPence / variableAmount //A Money / Money ratio is a Float - 6.0001282627 - so bound it, never == it require multiplier > 6.0001 and multiplier < 6.0002 variableAmount += 4.07#GBP require variableAmount == 160.00#GBP variableAmount *= 0.666 require variableAmount == 106.56#GBP variableAmount -= 0.56#GBP require variableAmount == 106.00#GBP variableAmount /= 4 require variableAmount == 26.50#GBP variableAmount := -variableAmount require variableAmount == -26.50#GBP variableAmount := abs variableAmount require variableAmount == 26.50#GBP variableAmount := sqrt variableAmount require variableAmount == 5.15#GBP variableAmount := variableAmount ^ 6 require variableAmount == 18657.07#GBP //Division by zero result is not set variableAmount := variableAmount/0 require ~variableAmount? //Use of two difference currencies together - result is not set variableAmount := tenPounds + thirtyDollarsTwentyCents require ~variableAmount? //Example if pipeline to add amounts together amounts as List of Money := [tenPounds, nintyNinePoundsFiftyOnePence, fourtyNinePoundsSeventySixPence] total <- cat amounts | collect as Money require total == 159.27#GBP //Provide the exchange rate and convert to USD totalInUSD <- total.convert(1.32845, "USD") require totalInUSD == 211.58#USD
- Divide one amount of money by another and you get a Float: how many times one goes into the other.
- Divide money by an Integer or Float and you get Money.
- To convert to another currency, give the exchange rate and the new currency code.
- Money is type safe: adding USD to GBP gives un set.
total <- cat [tenPounds, ninetyNinePoundsFiftyOnePence, fortyNinePoundsSeventySixPence] | collect as Money
Full source: builtInTypes_money_pipeline.ek9 - Download builtInTypes_money_pipeline.ek9
A pipeline takes its values from a collection, so to stream several Money values put them in a List and cat the list, as above. Money, like the other single-value types (Integer, Float, Date ...), is not itself a pipeline source. String and Bits are iterable, over their Characters and Booleans; to stream a single value of any type, use a List or an Optional.
Locale
A Locale outputs values in localised form. Its general form is {language}_{country}, for example en_GB or de_DE. These types can be localised out of the box, and Time, Date, DateTime and Money have short, medium, long and full formats:
- Integer
- Float
- Time
- Date
- DateTime
- Money
To localise 'Text' for end users, see Text Properties. The Locale and the Text construct together are the basis for output in a range of spoken languages.
The example formats the same data in several locales and formats, so you can see the differences:
#!ek9 defines module site.builtInTypes.locale defines program ShowLocale() enGB <- Locale("en_GB") //Note you can use underscore or dash as the separator. enUS <- Locale("en-US") deutsch <- Locale("de_DE") skSK <- Locale("sk", "SK") showIntegerFormats(enGB, deutsch, skSK) showFloatFormats(enGB, deutsch, skSK) showTimeAndDateFormats(enGB: enGB, enUS: enUS, deutsch: deutsch, skSK: skSK) showMoneyFormats(enGB, deutsch, skSK) defines function showIntegerFormats() -> enGB as Locale deutsch as Locale skSK as Locale i1 <- -92208 i2 <- 675807 presentation <- enGB.format(i1) require presentation == "-92,208" presentation := enGB.format(i2) require presentation == "675,807" presentation := deutsch.format(i1) require presentation == "-92.208" presentation := deutsch.format(i2) require presentation == "675.807" presentation := skSK.format(i1) require presentation == "-92 208" presentation := skSK.format(i2) require presentation == "675 807" showFloatFormats() -> enGB as Locale deutsch as Locale skSK as Locale f1 <- -4.9E-22 f2 <- -1.797693134862395E12 presentation <- enGB.format(f1) require presentation == "-0.00000000000000000000049" presentation := enGB.format(f2) require presentation == "-1,797,693,134,862.395" //With control over number of decimal places displayed presentation := enGB.format(f1, 22) require presentation == "-0.0000000000000000000005" presentation := enGB.format(f2, 1) require presentation == "-1,797,693,134,862.4" presentation := deutsch.format(f1) require presentation == "-0,00000000000000000000049" presentation := deutsch.format(f2) require presentation == "-1.797.693.134.862,395" presentation := skSK.format(f1) require presentation == "-0,00000000000000000000049" presentation := skSK.format(f2) require presentation == "-1 797 693 134 862,395" showTimeAndDateFormats() -> enGB as Locale enUS as Locale deutsch as Locale skSK as Locale time1 <- 12:00:01 presentation <- enGB.shortFormat(time1) require presentation == "12:00" presentation := deutsch.mediumFormat(time1) require presentation == "12:00:01" date1 <- 2020-10-03 presentation := enGB.shortFormat(date1) require presentation == "03/10/2020" presentation := enUS.mediumFormat(date1) require presentation == "Oct 3, 2020" presentation := skSK.longFormat(date1) require presentation == "3. októbra 2020" presentation := deutsch.fullFormat(date1) require presentation == "Samstag, 3. Oktober 2020" dateTime1 <- 2020-10-03T12:00:00Z presentation := enUS.shortFormat(dateTime1) require presentation == "10/3/20, 12:00 PM" presentation := enGB.mediumFormat(dateTime1) require presentation == "3 Oct 2020, 12:00:00" presentation := deutsch.longFormat(dateTime1) require presentation == "3. Oktober 2020, 12:00:00 Z" presentation := skSK.fullFormat(dateTime1) require presentation == "sobota 3. októbra 2020, 12:00:00 Z" showMoneyFormats() -> enGB as Locale deutsch as Locale skSK as Locale thousandsOfChileanCurrency <- 6798.9288#CLF tenPounds <- 10#GBP thirtyDollarsEightyNineCents <- 30.89#USD presentation <- enGB.format(tenPounds) require presentation == "£10.00" presentation := enGB.format(thirtyDollarsEightyNineCents) require presentation == "US$30.89" presentation := enGB.format(thousandsOfChileanCurrency) require presentation == "CLF6,798.9288" presentation := deutsch.format(tenPounds) require presentation == "10,00 £" presentation := deutsch.format(thirtyDollarsEightyNineCents) require presentation == "30,89 $" presentation := deutsch.format(thousandsOfChileanCurrency) require presentation == "6.798,9288 CLF" //Without the currency symbol presentation := deutsch.longFormat(tenPounds) require presentation == "10,00" presentation := deutsch.longFormat(thirtyDollarsEightyNineCents) require presentation == "30,89" presentation := deutsch.longFormat(thousandsOfChileanCurrency) require presentation == "6.798,9288" presentation := skSK.format(tenPounds) require presentation == "10,00 GBP" presentation := skSK.format(thirtyDollarsEightyNineCents) require presentation == "30,89 USD" presentation := skSK.format(thousandsOfChileanCurrency) require presentation == "6 798,9288 CLF" presentation := enGB.mediumFormat(tenPounds) require presentation == "£10" presentation := enGB.mediumFormat(thirtyDollarsEightyNineCents) require presentation == "US$31" presentation := enGB.format(money: thousandsOfChileanCurrency, showSymbol: true, showFractionalPart: false) require presentation == "CLF6,799" presentation := enGB.shortFormat(tenPounds) require presentation == "10" presentation := enGB.shortFormat(thirtyDollarsEightyNineCents) require presentation == "31" presentation := enGB.format(money: thousandsOfChileanCurrency, showSymbol: false, showFractionalPart: false) require presentation == "6,799"
It is long, but it shows the range of what you can localise and how to choose short, medium, long or full for each type.
Colour
Colour has colour manipulation built in. You can work with the Bits of a Colour to make new colours, add and subtract colours, and change transparency, saturation and lightness.
#!ek9 defines module site.builtInTypes.colour defines program ShowSimpleColour() //Colour can hold RGB or ARGB (i.e with alpha channel) //Here shown with alpha channel fully opaque testColour <- #FF186276 testColourAsBits <- testColour.bits() require testColourAsBits == 0b11111111000110000110001001110110 //Bitwise manipulation and colour recreation from bits modifiedBits <- testColourAsBits and 0b10110111000100000110001010110110 modifiedColour <- Colour(modifiedBits) require modifiedColour == #B7106236 require modifiedBits == 0b10110111000100000110001000110110 //Note it is also possible to access the HSL values of the colour require modifiedColour.hue() == 148 require modifiedColour.saturation() == 71.9298245614035 require modifiedColour.lightness() == 22.35294117647059 //Alter the alpha channel to control how transparent/opaque moreOpaqueColour <- modifiedColour.withOpaque(80) require moreOpaqueColour == #CC106236 //It can be made lighter - much lighter in this case lighterColour <- moreOpaqueColour.withLightness(80) require lighterColour == #CCA7F1CA //Then we can saturate it more moreSaturatedColour <- lighterColour.withSaturation(90) require moreSaturatedColour == #CC9EFAC9 //Remove some Red from this colour: '-' clears the bits given (#9E with #3A cleared is #84) lessRedColour <- moreSaturatedColour - #3A0000 require lessRedColour == #CC84FAC9 //Add in some blue: '+' sets the bits given (#C9 with #1D set is #DD) moreBlueColour <- lessRedColour + #00001D require moreBlueColour == #CC84FADD //Available in different formats as Strings require moreBlueColour.RGB() == $#84FADD require moreBlueColour.RGBA() == $#84FADDCC require moreBlueColour.ARGB() == $#CC84FADD
The actual colours from example above, shown with vertical bar background.
To show opaque and transparency.
#186276FF - testColour
#106236B7 - modifiedColour
#106236CC - moreOpaqueColour
#A7F1CACC - lighterColour
#9EFAC9CC - moreSaturatedColour
#84FAC9CC - lessRedColour
#84FADDCC - moreBlueColour
Programs often need to change the shade and hue of a colour, lightening and darkening it. With Colour, get the current Lightness, multiply it up or down by a factor, and make the alteration to get the new Colour. A pipeline could take a stream of Colours through a range of functions to produce a darker, lighter or more muted set.
Dimension
Dimension is a simple aggregate type: a size together with a unit of measurement. Its purpose is to make the units of a calculation explicit. You could build this yourself with a class hierarchy; EK9 provides it in the language.
Inspired by CSS dimensions, it takes these units.
Absolute lengths (m, km and mile are added; they are not for a CSS context):
- km - Kilometers
- m - Meters
- cm - Centimeters
- mm - Millimeters
- mile - Miles
- in - Inches
- pc - Picas (1pc = 12 pt)
- pt - Points (1pt = 1/72 of 1 inch)
- px - Pixels (1px = 1/96th of 1 inch)
Relative lengths (really only for screen rendering):
- em - Relative to the font-size (2em means 2 times the size of the current font)
- ex - Relative to the x-height of the current font
- ch - Relative to the width of the "0" (zero)
- rem - Relative to font-size of the root element
- vw - Relative to 1% of the width of the viewport
- vh - Relative to 1% of the height of the viewport
- vmin - Relative to 1% of viewport's smaller dimension
- vmax - Relative to 1% of viewport's larger dimension
- % - Relative to the parent element
An example of Dimension:
#!ek9 defines module site.builtInTypes.dimension defines program ShowDimensionType() dimension1 <- 1cm dimension2 <- 10px dimension3 <- 4.5em dimension4 <- 1.5em calc1 <- dimension1 * 2 calc2 <- dimension2 / 5 calc3 <- dimension3 + 0.6 calc4 <- dimension3 + dimension4 require calc1 == 2cm require calc2 == 2px require calc3 == 5.1em require calc4 == 6em //But if we divide a dimension by another dimension we get just a number calc5 <- dimension3 / dimension4 require calc5 == 3 //Normal comparison operators as you would expect require calc3 < calc4 //But also the coalescing operators lesser <- calc3 <? calc4 require lesser == calc3 calc3 += 0.9 require calc3 == calc4 calc3++ require calc3 > calc4 //Checking the calculation is not actually valid //different types of dimension calc1 is cm and calc2 is px require not (calc1 <> calc2)? //This is a test that the comparison was valid, not the result of the comparison require (calc3 < calc4)? calc6 <- sqrt calc4 require calc6 == 2.449489742783178em //Conversion is table-driven: name the target UNIT (1 mile is 1609.344 m by definition) eightMiles <- 8mile inKM <- eightMiles.convert("km") require inKM == 12.874752km require #< inKM == 12.874752 returnJourney <- -eightMiles require abs returnJourney == eightMiles squared <- inKM ^ 2 require squared == 165.75923906150402km result <- eightMiles + inKM require not result?
Why EK9 does it this way. Dimension is strong typing for units: only compatible units are used together, or they are converted with an explicit method call. The Mars Climate Orbiter shows why that is 'quite important'.
Path
Like JSON (next), the object/array path is built into the language, because an object graph mixing maps and arrays is so common.
A path literal starts with $?, followed by any combination of property and array addressing:
- Properties - .property-name
- Arrays - [index]
#!ek9 defines module site.builtInTypes.jsonPaths defines program ASimpleSetOfPaths() stdout <- Stdout() path1 <- $?.some.path.inc[0].array path2 <- $?.another[2][1].multi-dimensional.array.access_value simplePath <- $?.aKey firstArrayElement <- $?[0] propertyFromFirstElement <- $?[0].a-field stdout.println(`${path1} ${path2} ${simplePath} ${firstArrayElement} ${propertyFromFirstElement}`) //Not to be confused with string conversion anInt <- 90 stringRepresentation <- $anInt stdout.println(stringRepresentation) //Or json conversion of a record. me <- CustomerDetail(firstName: "Steve", lastName: "Limb") jsonOfMe <- $$me stdout.println(jsonOfMe) defines record CustomerDetail firstName as String: String() lastName as String: String() CustomerDetail() -> firstName as String lastName as String this.firstName: firstName this.lastName: lastName default operator ? default operator $$
Download builtInTypes_json_paths.ek9
It prints the paths, the String of an Integer, and the JSON of a record:
$?.some.path.inc[0].array $?.another[2][1].multi-dimensional.array.access_value $?.aKey $?[0] $?[0].a-field
90
{"firstName":"Steve","lastName":"Limb"}
JSON
JSON is built into the language and supports all the JSON types:
- String - EK9 String
- Number - EK9 Integer/Float
- Boolean - EK9 Boolean
- null - EK9 unset value
- object - EK9 aggregates/Dict
- array - EK9 List
Other EK9 types, such as Character, Date, Time, DateTime, Duration, Millisecond, Money, Colour, Dimension and Bits, become JSON Strings.
Your own records and classes convert to JSON with the $$ operator, and for most records EK9
can generate it for you (default operator $$). To convert them back from JSON, give the type either:
- a constructor that takes a single JSON argument, or
- a default constructor and a pipe '|' operator that takes a single JSON argument.
A stream can be poured into a JSON (cat items > target, or collect as JSON). Pouring follows the target's shape:
- An unset or array target collects; each item is one element of the array (an array poured in is not spread).
- An object target merges each streamed object in, by the merge rule, so the first value recorded wins.
- Anything else (a non-object into an object, or anything into a single value) is invalid: it leaves the target unset, and later items do not revive it.
- A JSON null item is unset, so it is dropped.
See pouring into JSON.
JSON is a value. Merging, pouring, combining or converting into JSON copies what it takes: +, :~:, :^:, |, :=: and $$ (of a JSON, or of anything holding one) all copy, and so do iterating a JSON and read(). Changing an operand afterwards never reaches the result, and a JSON can never come to contain itself. The one exception is get(): it is a view into its container, so merging into what get() returns changes the container too.
CSV
CSV is a first-class construct for reading and writing
RFC 4180 Comma Separated Values, modelled on
JSON. It handles what a naive split(',') gets wrong: quoted fields
containing the separator, doubled ("") quotes, and newlines inside quoted fields. The separator is
configurable, so tab- and semicolon-separated data work too.
- parse returns a Result of (CSV, String): a malformed document carries its reason, and the compiler requires you to check isOk() before using the value, so you cannot use a half-parsed document by accident.
- Rows are read total-style with getOrDefault(index, default) (as on List), and iterator() feeds the rows to stream pipelines, so filtering, sorting and so on come for free.
- Stacking CSVs vertically ('+', '+=') checks the headers align: stacking two CSVs with different headers gives un set rather than welding mismatched columns together.
- Merge (':~:') does not stack; it follows the merge rule. A wholly un set CSV takes the source; otherwise only what the target lacks is filled (a missing header, rows past its last, cells that are missing or empty) and nothing it holds is overwritten.
Its API:
- parse - read text into a Result of (CSV, String)
- header - the header CSVRow
- rows - how many data rows
- getOrDefault(index, default) - the data row at a position, or the default
- iterator - iterate the data rows (feeds stream pipelines)
- '+=' a CSVRow to append a row, or a CSV to append rows (header-aligned)
- '$' - render back to RFC 4180 text
#!ek9 defines module site.builtInTypes.csv defines program CsvExample() stdout <- Stdout() result <- CSV().parse("name,weight\nHydrogen,1.008\nHelium,4.0026") if result.isOk() table <- result.ok() //Read a data row by column name; convert the cell with the target type's constructor helium <- table.getOrDefault(1, CSVRow()) weight <- BigDecimal(helium.getOrDefault("weight", "0")) stdout.println(`Helium weighs ${weight}`) else if result.isError() stdout.println(`Bad CSV: ${result.error()}`)
It prints:
Helium weighs 4.0026
CSVRow
A CSVRow is one row of a CSV; the same type serves the header and every data row. It is more than a List of String: a data row is header-aware, so a cell can be read by column name as well as by position. Access is total, as on List: getOrDefault(key, default) never goes out of range, it yields the default.
- getOrDefault(index, default) - cell by position
- getOrDefault(name, default) - cell by column name (data rows only)
- length - number of fields; empty; contains; iterator
- '+=' a String to append a field (building rows)
- '$' - render as one RFC 4180 line (fields quoted as needed)
Cells are text. For a real type, hand the cell to that type's constructor, which parses it and returns
un set if the cell is not that type: Integer(row.getOrDefault(1, "0")) or
BigDecimal(row.getOrDefault("weight", "0")). This works the same for every type, your own included,
and is safer than dedicated typed getters: a bad cell becomes un set rather than throwing.
Regular Expression
RegEx is the last of the built-in non-collection types. It is built into the language because regular expressions are so widely used and so expressive (if complex).
Standard shorthand sequences
- . - Any character (may or may not match line terminators)
- \d - A digit: [0-9]
- \D - A non-digit: [^0-9]
- \s - A whitespace character: [ \t\n\x0B\f\r]
- \S - A non-whitespace character: [^\s]
- \w - A word character: [a-zA-Z_0-9]
- \W - A non-word character: [^\w]
Quantifiers
-
X, once or not at all
- X? - Greedy
- X?? - Reluctant
- X?+ - Possessive
-
X, zero or more times
- X* - Greedy
- X*? - Reluctant
- X*+ - Possessive
-
X, one or more times
- X+ - Greedy
- X+? - Reluctant
- X++ - Possessive
-
X, exactly n times
- X{n} - Greedy
- X{n}? - Reluctant
- X{n}+ - Possessive
-
X, at least n times
- X{n,} - Greedy
- X{n,}? - Reluctant
- X{n,}+ - Possessive
-
X, at least n times but not more than m times
- X{n,m} - Greedy
- X{n,m}? - Reluctant
- X{n,m}+ - Possessive
Boundary Matchers
- ^ - The beginning of a line
- $ - The end of a line
- \b - A word boundary
- \B - A non-word boundary
- \A - The beginning of the input
- \G - The end of the previous match
- \Z - The end of the input, before any final line terminator
- \z - The end of the input
An example of RegEx with match, split and group; each operation can start from the RegEx or from the String.
#!ek9 defines module site.builtInTypes.regex defines program ShowRegExType() stdout <- Stdout() sixEx <- /[a-zA-Z0-9]{6}/ require sixEx matches "arun32" require sixEx not matches "kkvarun32" require sixEx matches "JA2Uk2" require sixEx not matches "arun$2" regEx <- /[S|s]te(?:ven?|phen)/ firstCapital <- "Steve" firstLower <- "steve" secondCapital <- "Stephen" secondLower <- "stephen" thirdCapital <- "Steven" thirdLower <- "steven" fourthCapital <- "Stephene" fourthLower <- "stephene" require firstCapital matches regEx and firstLower matches regEx require secondCapital matches regEx and secondLower matches regEx require thirdCapital matches regEx and thirdLower matches regEx require fourthCapital not matches regEx require fourthLower not matches regEx //String and Regex can work either way around require regEx matches secondCapital //Examples using escape of '\' stockExample <- "This order was placed for QT3000! OK?" groupEx <- /(.*?)(\d+)(.*)/ require groupEx matches stockExample fractionExample <- "3/4" fractionEx <- /.*\/.*/ require fractionEx matches fractionExample slashEx1 <- /^Some\\Thing$/ require slashEx1 matches "Some\Thing" slashEx2 <- /^Some\/Thing$/ require slashEx2 matches "Some/Thing" colonDelimited <- "one:two:three:four:five" colonRegEx <- /:/ //You can work with RegEx first or String first whenSplit <- colonDelimited.split(colonRegEx) alsoSplit <- colonRegEx.split(colonDelimited) //$ on a List renders the collection with its String elements quoted - it is not a join require length whenSplit == 5 require whenSplit.getOrDefault(0, "?") == "one" require whenSplit.getOrDefault(4, "?") == "five" require whenSplit == alsoSplit //Check on finding groups - the capture groups of the first match, in pattern order extractionCheck <- "This is a sample Text 1234 with numbers in between." extractNumbersEx <- /(.*?)(\d+)(.*)/ matchedGroups <- extractionCheck.group(extractNumbersEx) //Three groups: the text before, the digits, the text after require length matchedGroups == 3 justTheNumber <- cat matchedGroups | skip 1 | head 1 | collect as String //Convert to an Integer - if possible asAnInteger <- Integer(justTheNumber) require $justTheNumber == "1234" require asAnInteger == 1234 //Check just the last two (as an example) lastTwo <- cat matchedGroups | tail 2 | collect as List of String require length lastTwo == 2 nonExtractionCheck <- "Another sample but with no numbers in it." matchedGroups := nonExtractionCheck.group(extractNumbersEx) //Expecting this to be unset require not matchedGroups? //Safe way of accessing some result that might not be set (in this case it is not set) justNotTheNumber <- cat matchedGroups | skip 1 | head 1 | collect as String require not justNotTheNumber? //Named groups: ask for one by name - a String, unset when absent namedDate <- /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/ dueLine <- "Due 2024-06-15 at noon" require dueLine.group(namedDate, "year") == "2024" require not dueLine.group(namedDate, "hour")? //Replace every match, referring to the groups as $n or ${name} - a name as \${name} in backticks require dueLine.replace(namedDate, "$3/$2/$1") == "Due 15/06/2024 at noon" require dueLine.replace(namedDate, `\${day}.\${month}.\${year}`) == "Due 15.06.2024 at noon" stdout.println(`Replaced [${dueLine.replace(namedDate, "<date>")}]`)
Every require holds, and it prints:
Replaced [Due <date> at noon]
The example only touches on what regular expressions can do, but it shows how writing them directly in the code cuts down the escape sequences you need.
A RegEx literal (/\|/) goes to the regular expression engine exactly as written. This is the recommended approach. A RegEx built from a String goes through the String's escaping first, so a backslash meant for the engine is written twice: RegEx("\\|") is the pattern \|, the same as /\|/. Use regex literals where you can and the question never arises.
Summary
EK9 builds a wide range of types into the language. Types such as Time, Date, DateTime, Money, Dimension and Colour give you strong typing with consistent, simple semantics.
Next Steps
Many of the examples above use a collection type; the collection types section covers them.