Types and nullability#
Meta: nullability is the page's main attraction. Most readers won't have seen GraphQL-style T! outside of a schema and may misread it as "definitely-null" or "force-unwrap."
Built-in types#
Int!,Float!,String!,Boolean!,ID![T]and[T]!— lists{{ ... }}— records- custom GraphQL scalars:
URL,Timestamp,JSON, ... - user-defined:
type,interface,union,enum,scalar(see their pages)
The ! sigil#
T!— non-nullTT(no bang) — nullableT- assignability:
T!satisfiesT, butTdoes not satisfyT!(nullcan't init aT!slot) - grammar:
!is a postfix wrapper applied to a (possibly list) type —NonNull <- inner:Type BangToken. So[String!]!parses asNonNull(List(NonNull(String)))= non-null list of non-null strings - object/
typeliterals are always non-null in Dang (no nullable-object form) [T]andInt!are unrelated — you can't assignInt!to[Int]
Lists, nullability matrix#
| written | meaning |
|---|---|
[T] |
nullable list of nullable T |
[T]! |
non-null list of nullable T |
[T!] |
nullable list of non-null T |
[T!]! |
non-null list of non-null T |
Null propagation#
nullable.fieldis nullable even iffield: T!- chains short-circuit:
a.b.cis null if any link is null - recovers via
??(see Operators) or flow narrowing (next section)
Flow-sensitive narrowing#
Meta: this section pays for itself — the alternative is writing ! casts everywhere. Make sure the examples include both narrowing inside a branch and narrowing after a guard clause.
Null narrowing#
After a guard:
if (x == null) { return "no value" }
# x is now T! here
print(x.length)
Inside a branch:
if (x != null) {
# x is T! here
print("got " + x)
}
Else branch:
if (x == null) { print("no x") } else {
# x is T! here
}
Loop guards:
loop {
if (x == null) { break }
# x is T! after the guard
x = x.next
}
Diverging constructs are narrowing-aware#
return,raise,break,continueall count as diverging- code after them in the same scope sees the narrowed type
- a guard whose then-branch diverges narrows the rest of the enclosing scope
else ifchains: the parser wrapselse ifin a Block, so the outer guard's falsy facts still apply afterward- sequential guards accumulate: each narrows independently as forms are processed in order
- inside a
loop/.eachblock, a guard thatbreaks orcontinues narrows the rest of that iteration (if (x == null) { break }→xisT!after)
Type narrowing via case#
case (animal) {
c: Cat => c.purr # c is Cat!
d: Dog => d.bark # d is Dog!
}
binding: TypeName => …clauses bind the operand narrowed to the pattern typetry/catchclauses reuse the sameCaseClauseform, so typed catch clauses narrow the bound error the same way- this is how you recover a concrete value from a widened conditional: an
if/elseover divergent branches infers as a union, which acasethen narrows
Conditional result inference (related)#
- an
if/elsewhere one branch isnullinfers a nullable type, not non-null - divergent concrete branches widen to their common interface/supertype, or to a union when unrelated
- a discarded divergent conditional is fine; only using the result forces the union/narrowing
Compound conditions#
if (x == null or y == null) { raise "missing" }
# both x and y are T! after the diverging guard
- guard with
or: entering the diverging branch means both checks failed, so both narrow afterward - compound
andinside a then-branch narrows both operands in that branch:
if (maybe != null and other != null) {
maybe + other # both T! here
}
Limitations#
- narrowing is intra-procedural — calling a function doesn't carry narrowed types across
and-guard does NOT narrow:if (x == null and y == null) { raise … }tells us only that at least one is non-null afterward, so neither narrows individually- field accesses don't narrow: a null check on
h.valdoes not narrow laterh.valaccesses, because each.fieldaccess could return a different value. Workaround: bind to a local first —let v = h.val; if (v == null) { … } - in an
elsebranch where the guard checked== null, the variable is known null (not narrowed toT!) — using it as non-null there errors - narrowing applies to bare symbols (locals, and bare
self-field references inside methods, which parse as plainSymbols)
When narrowing can't reach the value — a field or call result, or a spot where the checker just can't follow your reasoning — the postfix ! operator is the explicit escape hatch: expr! narrows T to T! and raises at runtime if the value turns out to be null. See Operators.
See also Errors: try, catch, raise (raise/try/catch divergence) and Control flow (guards, loops, case).
Meta: field-narrowing and the and-guard non-narrowing are the two most surprising gaps in practice. Both are documented above with the re-bind-to-a-local workaround.
Type variables#
- single lowercase letters:
a,b - used in generic function signatures
- inferred at call sites
- opaque: a generic value supports no operations. Inside the body that declares it, a type variable can only be passed through (returned, stored, passed on) or compared with
==/!=— arithmetic and ordering are definition-time errors (do(&yield: b): b { yield * 2 }→ "operator multiplication is not defined for the generic type b"). Use a concrete type (e.g.Int) if the body must operate on the value.
Type hints / casts: ::#
expr :: Type!annotates an expression's type (TypeHintnode)- grammar: binds only a bare
Termon the left, so wrap compound exprs in parens —(a + b) :: T!(see Operators for precedence) ::is the explicit materialization/coercion boundary:String→ custom scalar (URL!,Timestamp!, …), enum ("PASSED" :: Status!), andIDcoercions go here- coercion source is limited: only
Stringvalues coerce to custom scalars/enums.42 :: URL!is rejected statically ("type hint mismatch: Int!, but hint expects URL!") - enum casts are checked at runtime:
"NOPE" :: Status!→ "invalid enum value" - nullable → non-null casts do not strip the wrapper statically; they defer to a runtime
Coercethat rejects null:fromJSON("null") :: String!→ "null is not allowed for String!"
Meta: :: deserves a worked example showing the difference between a type hint (narrowing/disambiguation, e.g. [] :: [String!]! binding a type variable) and a type cast (coercion, e.g. myUrl :: URL!). The runtime-assertion behavior for non-null casts is a footgun worth a short callout.
Coercion rules#
- assignment / arguments: pure subtyping (
hm.Assignable), no scalar coercion — a non-literalString!won't pass whereURL!is expected:fetchURL(url: myUrl)errors "cannot use String! as Test.URL!" - exception: literal expressions (string/template literals, list literals of them) auto-coerce to compatible scalars at value-handoff boundaries (call args, typed slots, returns) —
fetchURL(url: "https://…")is fine, andfetchURL(url: `https://${host}`)works too ::casts: explicitString/enum/IDcoercion permitted (see above)- list merges are pure —
String!does not becomeID!element-wise - ongoing work — see
soundness.mdfor the model being moved toward