Part VII — The gradual type system

Quoin is dynamic by default: nothing in the language requires a type annotation. But every annotation position you've seen so far — typed block params (§7), typed multimethod params (§13) — belongs to one coherent, gradual type system: optional annotations, a best-effort compile-time checker (qn check), runtime-checked collections, and an optimizer that consumes the parts it can trust.

Nav: Foundations · Blocks & control · Objects · Patterns & errors · Concurrency & iteration · Networking & the web · Types · Tooling · Library & reference · Appendices


28. Types are optional

Rules

Dynamic Quoin is complete on its own. This method has no annotations and happily serves two types:

Doubler <- {
    double: -> { |n| n + n }
}

Doubler.new.double:21        "* -> 42
Doubler.new.double:'ho'      "* -> 'hoho'

Add a type to the parameter and the method's contract becomes real: dispatch only selects the variant when the argument actually is an Integer, so a wrong argument is a MessageNotUnderstood at the call — not a strange result later:

Halver <- {
    half: -> { |n: Integer ^Integer| n / 2 }
}

Halver.new.half:42                                    "* -> 21
{ Halver.new.half:'ho' }.catch:{ |e| e.class.name }   "* -> 'MessageNotUnderstood'

The stance throughout: nothing requires types; everything benefits from them. Annotate where a contract matters (public methods, collection contents, nullable data) and leave exploratory code bare.


29. Annotation syntax

Rules

PositionSyntax
Block/method parameter{ |n: Integer| … }
Return type (in the header, after the params){ |n: Integer ^Integer| … }
Local declarationvar x: Integer = 5, let y: String = 'hi'
Block-local (after the -){ |a - tmp: Integer| … }
Class-header type variableStack(T) <- { … }
var count: Integer = 0                            "* typed local
var title: String? = nil                          "* nullable: String or nil
var xs: List(Integer) = #(1 2 3)                  "* checked collection (§33)
var index: Map(String Integer) = #{ 'ada': 1 }    "* value type Integer; keys are String
var pred: Block(Integer ^Boolean) = { |n| n > 0 } "* a block type
pred.value:3                                      "* -> true

Return types sit in the header after the parameters, marked with ^ — the same character as the return statement, in a type position:

Circle <- { |@r|
    area -> { |^Double| 3.14159 * @r * @r };
    grow: -> { |k: Double ^Double| @r = @r * k; .area }
}

(Circle.new:{ r = 2.0 }).area          "* -> 12.56636

Because a block's type is exactly its header minus the names, the two sides of this correspondence read the same way:

{ |a: Integer b: Integer ^Integer| a + b }     "* a value of type…
Block(Integer Integer ^Integer)                "* …this type

⚠ Gotcha — in the block role, annotations are not runtime checks. value: binds arguments with no arity or type checking (§7), so a typed block param is documentation plus checker/optimizer input — not a guard. Only the method role enforces parameter types, because there dispatch itself does the checking (§32):

{ |x: Integer| x + 1 }.value:'nope'    "* -> 'nope1'

30. The checker: qn check

Rules

Given a file with three bugs:

"* stats.qn
Stats <- {
    label: -> { |score: Integer ^String| score };

    firstOver: -> { |xs: List(Integer) ^Integer|
        xs.detect:{ |n| n > 100 }
    };

    span -> { var r: NumberRange = 1..10; r.middle }
}

qn check stats.qn reports:

stats.qn:2:42: warning: type mismatch: expected `String`, found `Integer`
    |
  2 |     label: -> { |score: Integer ^String| score };
    |                                          ^^^^^
  stats.qn:2:18: note: `score` is `Integer` (parameter)
    |
  2 |     label: -> { |score: Integer ^String| score };
    |                  ^^^^^
stats.qn:5:9: warning: type mismatch: expected `Integer`, found `Integer?`
    |
  5 |         xs.detect:{ |n| n > 100 }
    |         ^^^^^^^^^^^^^^^^^^^^^^^^^
stats.qn:8:43: warning: `NumberRange` does not respond to `middle`
    |
  8 |     span -> { var r: NumberRange = 1..10; r.middle }
    |                                           ^

Each diagnostic carries the location, the message in Quoin's own type names, the offending line with a caret, and — where an inference is involved — a note: tracing why the checker believes what it believes (score is Integer because the parameter says so).

The three warnings show the checker's range. The first is a straight declaration-vs-value mismatch. The second is nullable honesty: detect: can come up empty, so its result is Integer?, which doesn't satisfy a declared ^Integer (§31). The third is a compile-time MNU — but note what made it possible: NumberRange is a sealed, already-loaded class, so no future code can add middle to it, and the checker can prove the send fails. On an open class the same send stays silent — some later <-- extension could legitimately add the method, and a false warning is worse than a missed one.

The checker also enforces the override contract that keeps inherited types meaningful — an override must return a subtype of what the base method declares (this is what lets the checker trust x.defined? to be Boolean for any receiver, §31):

Sneaky <- {
    defined? -> { |^String| 'always' }
}
override.qn:2:21: warning: override of `defined?` returns `String`, incompatible with `Boolean` from `Object`
    |
  2 |     defined? -> { |^String| 'always' }
    |                     ^^^^^^

One convenience the checker allows rather than flags: an Integer literal in a Double position is promoted to the double it obviously means, at the value level:

var d: Double = 1        "* no warning: the literal 1 becomes 1.0
d.class.name             "* -> 'Double'

Finally, the escape hatch. Sometimes the flagged behavior is the point — a test that wants to send if: to a maybe-nil value to pin the runtime error, say. A trailing allow: comment silences exactly that warning kind, exactly there:

(flags.at:n).if:{ ^^1 };  "* allow: nil-receiver (nil -> MNU is the point)

The kind name is the warning's family — nil-receiver, caret-discard, mnu, no-variant, element-type, type-mismatch, return-type, unknown-type, annotation — and several can be listed, separated by commas. A parenthesized rationale is encouraged and ignored by the parser. Three rules keep suppressions honest: the pragma must trail the warned line (on a line of its own it would be captured as a doc comment, so that placement warns instead of silently doing nothing); an unknown kind name warns rather than no-ops, so a typo can't leave a phantom suppression; and a pragma only reaches its own line, so it can't blanket a file.


31. Nullable types & nil narrowing

Rules

GuardNarrows
x.defined?.if:{A} else:{B}A: x is T · B: x is nil
x.defined?.else:{ ^^ … }rest of the method: x is T
(x != nil).if:{A} / (x == nil)same, polarity flipped
x.defined? && exprexpr sees x as T

Dereferencing a nullable without a guard draws the warning:

Badge <- {
    width: -> { |name: String? ^Integer| name.length + 4 }
}
badge.qn:2:42: warning: receiver of `length` may be nil
    |
  2 |     width: -> { |name: String? ^Integer| name.length + 4 }
    |                                          ^^^^

Guard it and the same send is silently fine — the checker follows the flow. The early-return form reads especially well: handle absence once, then the rest of the method sees the narrowed type:

Grades <- {
    letterFor:in: -> { |name: String scores: Map(String Integer) ^String|
        var n: Integer? = scores.at:name;
        n.defined?.else:{ ^^ 'absent' };      "* from here on, n is Integer
        (n >= 90).if:{ 'A' } else:{ 'B or below' }
    }
}

var g = Grades.new
var scores: Map(String Integer) = #{ 'ada': 97 }
g.letterFor:'ada' in:scores        "* -> 'A'
g.letterFor:'grace' in:scores      "* -> 'absent'

This is the static complement of the strict-conditional rule from §8: nil.if: is a runtime error, and x.defined?.if:{ … } is both the idiomatic guard and the thing the checker understands.

var scores: Map(String Integer) = #{ 'ada': 97 }
var n: Integer? = scores.at:'grace'
n.defined?.if:{ 'present' } else:{ 'absent' }      "* -> 'absent'

A T? parameter participates in dispatch as base-type-or-nil: a |name: String?| variant matches a String argument at the base type's specificity, and matches nil exactly — so a nullable variant can sit beside concretely-typed siblings:

W <- {
    width: -> { |name: String?| name.defined?.if:{ 'str' } else:{ 'none' } };
    width: --> { |n: Integer| 'int' }
};
var w = W.new;
w.width:'x'    "* -> 'str'
w.width:nil    "* -> 'none'
w.width:7      "* -> 'int'

32. Types at dispatch time

Rules

Triage <- {
    rank: -> { |n: Integer { n > 100 }| 'big' };
    rank: --> { |n: Integer| 'small' };
    rank: --> { |x| 'not a number' }        "* untyped catch-all
}

var t = Triage.new
t.rank:400          "* -> 'big'
t.rank:7            "* -> 'small'
t.rank:'seven'      "* -> 'not a number'

Without a catch-all, a wrong-typed argument fails loudly — and helpfully:

Describer <- {
    describe: -> { |n: Integer| 'int ' + n.s };
    describe: --> { |s: String| 'str ' + s }
}

Describer.new.describe:7        "* -> 'int 7'
Describer.new.describe:'hi'     "* -> 'str hi'
{ Describer.new.describe:3.14 }.catch:{ |e: MessageNotUnderstood| e.message }
                                "* -> 'no method \'describe:\' for Describer'

(Uncaught, the same error also prints the candidate variants — see §35.)

Generic collection types make dispatch element-aware, because tagged collections really know their element type at runtime (§33):

Render <- {
    show: -> { |xs: List(Integer)| 'all ints' };
    show: --> { |xs: List| 'any old list' }
}

var r = Render.new
r.show:(#(1 2).ensure:Integer)      "* -> 'all ints'
r.show:#(1 2)                       "* -> 'any old list'
r.show:#(1 'two')                   "* -> 'any old list'

The untagged #(1 2) falls through to the bare List variant even though its elements happen to be integers — dispatch trusts tags (guarantees), never inspection of the current contents.


33. Checked generic collections

Rules

var xs = List.of:Integer
xs.add:3
xs.elementType                            "* -> #Integer

var bad = #('one').at:0                   "* arrives dynamically — invisible to the checker
{ xs.add:bad }.catch:{ |e| e.message }    "* -> 'List(Integer): element must be Integer, got String'
xs.count                                  "* -> 1

The insertion check is the entire mechanism — because writes are guarded, reads need no checks, and whatever comes out of a List(Integer) is provably an Integer or nil. Construction through an annotated literal checks up front:

{ var nope: List(Integer) = #(1 'two' 3); nope }.catch:{ |e| e.message }
    "* -> 'List(Integer): element at 1 must be Integer, got String'

Decoded data is inherently dynamic, so decoders never guess a tag — ensure: is the explicit opt-in:

var raw = JSON.parse:'[1, 2, 3]'
raw.elementType                           "* -> nil
(raw.ensure:Integer).elementType          "* -> #Integer
{ #(1 'two').ensure:Integer }.catch:{ |e| e.class.name }    "* -> 'TypeError'

Tags flow through the combinators that preserve elements, and honestly don't through the one that transforms them:

var days: List(String) = #('monday' 'tue' 'wednesday')
(days.select:{ |d| d.length > 4 }).elementType    "* -> #String
days.reverse.elementType                          "* -> #String
(days.collect:{ |d| d.length }).elementType       "* -> nil

On the checker side, declared element types catch both directions of mistake — bad writes and unguarded reads:

Inventory <- {
    restock: -> { |counts: Map(String Integer)|
        counts.at:'bolts' put:'twelve'
    };

    firstCount: -> { |counts: Map(String Integer) ^Integer|
        counts.at:'bolts'
    }
}
inventory.qn:3:31: warning: `Map(String Integer)` rejects a `String` element — this raises a TypeError at runtime
    |
  3 |         counts.at:'bolts' put:'twelve'
    |                               ^^^^^^^^
inventory.qn:7:9: warning: type mismatch: expected `Integer`, found `Integer?`
    |
  7 |         counts.at:'bolts'
    |         ^^^^^^^^^^^^^^^^^

⚠ Gotcha — ensure: copies; tagging is never in-place. Retagging a list aliased elsewhere would change its behavior under someone else's feet, so ensure: always returns a fresh collection and leaves the receiver untagged. Note the type-argument forms are for type positions only: construction and conversion take an ordinary class value (List.of:Integer, .ensure:Integer) — List(Integer).new is not an expression.


34. Sealing

Rules

The visible consequence: you cannot monkey-patch or subclass the sealed built-ins —

{ Integer <-- { double -> { self * 2 } } }.catch:{ |e| e.message }
    "* -> 'Cannot extend sealed class [/]Integer'

— extend String (open) or wrap a sealed type in your own class instead. Your own classes can buy the same guarantees:

Money <- { |@cents|
    cents -> { @cents };
    .sealed!
}

{ Money <-- { inflate -> { 0 } } }.catch:{ |e| e.class.name }    "* -> 'ClassError'
{ Money <- Coupon <- {} }.catch:{ |e| e.class.name }    "* -> 'ClassError'

Extension and subclass attempts both raise a typed ClassError, so one catch:{ |e: ClassError| … } covers either.

abstract! is the other, independent switch — a class that exists only to be subclassed:

Shape <- {
    .abstract!;
    describe -> { 'a ' + .class.name + ' of area ' + .area.s }
}
Shape <- Circle <- { area -> { 314 } }

{ Shape.new }.catch:{ |e| e.message }    "* -> 'Cannot instantiate abstract class [/]Shape'
Circle.new.describe                      "* -> 'a Circle of area 314'

35. Errors at runtime, warnings at compile time

Rules

MistakeWhen caughtAs
Reading an unbound nameruntimeNameError
Unknown selectorruntime (compile-time warning when provable, §30)MessageNotUnderstood
Wrong-typed argument to a typed methodruntime, at dispatchMessageNotUnderstood + candidate list
Bad insertion into a tagged collectionruntime (warning when statically visible)TypeError
Everything else the checker seescompile timenon-fatal warning

A misspelling fails at the read, not three calls later as a mysterious nil:

{ typoedName }.catch:{ |e: NameError| e.message }
    "* -> 'undefined name `typoedName` — nothing with that name is in scope'

Class.exists?:#Integer         "* -> true
Class.exists?:#Wibble          "* -> false
Class.exists?:#'[IO]File'      "* -> true

A NameError can't be checked at compile time — use runs at run time and a method may name a class defined later in the file, so the read site is the first place the answer is knowable.

And when typed dispatch rejects every variant, the uncaught error names the candidates it filtered out. Running the Describer of §32 against a Double:

Describer.new.describe:3.14
VM execution error: Message not understood: receiver=Describer, selector='describe:', args=[Double]
  describe:Integer
  describe:String
  at describe.qn:5:1
  |
  | Describer.new.describe:3.14
  |

The summary of the whole chapter is in that table: the checker warns early where types are written, the runtime enforces the three real guarantees (dispatch, tags, seals), and dynamic code sails through both untouched.


Next: Part VIII — Tooling.