Unions#
A union is a value that is one of several types, written with |:
local type Either = string | integerTwo shapes of union earn their own names, because they are what other languages reach for a keyword to express: a union of literals is a closed set of values, and a union of records sharing a literal-typed field is a tagged union. See Primitive types for how a union interacts with nil and the optional shorthand T?.
Literal unions are enums#
A string literal is a type containing exactly that value, so a union of them is a closed set of strings:
local type Color = "red" | "green" | "blue"Nupp has no enum declaration, and a literal union is how one is written. Nothing is declared at run time, since the value is the plain string, and a bare literal lands in the union:
local c: Color = "red"A string that is not a member is rejected, and the message says which values were on offer:
local c: Color = "purple"
-- NUPP2001: "purple" is not a "blue" | "green" | "red"Because the value is a string, everything that works on strings works here, and a member is also accepted where a cstring is wanted.
A union may mix a closed set with an open type, which gives up exhaustiveness but keeps the named cases:
local type Reply = "ok" | "retry" | integerBoolean literals are types too. false exists as one on its own so that T | false narrows usefully:
local type Flag = string | falseAn alias is transparent, so Color and its union are interchangeable. That is also why a diagnostic prints the members rather than the alias: there is no nominal identity behind the name to print instead.
Record unions are tagged unions#
A literal member carries no data. When the alternatives need to, give each record a field whose type is a literal, the tag, and union the records:
local record Circle
kind: "circle"
radius: number
end
local record Square
kind: "square"
side: number
end
local type Shape = Circle | SquareComparing the tag narrows the union to the one record that declares it, so the fields of that arm are reachable and the other arm's are not:
local function area(shape: Shape): number
if shape.kind == "circle" then
-- shape is Circle here: `radius` resolves, `side` does not
return 3.14159 * shape.radius * shape.radius
end
-- shape is Square here
return shape.side * shape.side
endConstruction fills the tag like any other field:
local s: Shape = Circle{kind = "circle", radius = 2}The tag is an ordinary field, so it survives to run time and a print of the value shows it.
Dive deeper
The discriminant costs a field, which a nominal sum type would not spend. What it buys is that the value stays a plain Lua table with no hidden header: it serializes through nupp.codec.json, compares field by field, prints readably, and crosses a boundary to untyped Lua without a wrapper. A nominal encoding would have to hide the tag somewhere the runtime can still find it, which means either a metatable, and then a decoded table is not one of these, or a reserved key, and then the cost is the same field under a name nobody chose.
A tag copied into a local is still a tag:
local function areaVia(shape: Shape): number
local kind = shape.kind
if kind == "circle" then
-- shape is Circle here too
return 3.14159 * shape.radius * shape.radius
end
return shape.side * shape.side
endAssigning to shape, or to anything the copy came from, drops what the copy proved. See Narrowing for how long a fact lasts.
Success and failure#
The arms need share nothing but the tag, so the same shape carries a result and the reason it is not one:
local record Ok
kind: "ok"
value: string
end
local record Err
kind: "err"
message: string
end
local type Result = Ok | Err
local function describe(r: Result): string
if r.kind == "ok" then
return "ok: " .. r.value
end
return "failed: " .. r.message
endExhaustiveness#
A closed set of literals is worth having only if something checks that every member was handled. Two constructs do, and they answer to different severities.
Switch exhaustiveness#
A switch expression checks exhaustiveness as a type error rather than a lint, because the expression must always produce a value:
local function describe(c: Color): string
return switch c do
case "red" -> "warm"
case "green", "blue" -> "cool"
end
endCases subtract their exact values from the remaining selector union. An else is required for an open alternative such as string or integer, but is unreachable once a closed union has been consumed. 1, 1.0, and 1e0 name one numeric value and therefore count as duplicate cases. See Switch expressions for the reachability rules that go with them.
Unions that may grow#
A union that lists nupp.types.nonExhaustive() among its alternatives carries one member no value inhabits, no name resolves to, and no case can cover:
local type Status = "ok" | "error" | nupp.types.nonExhaustive()
local function describe(status: Status): string
return switch status do
case "ok" -> "fine"
case "error" -> "broken"
else -> "unrecognized"
end
endlocal function describe(status)
local __nuppT1 = status
local __nuppT2
if __nuppT1 == "ok" then __nuppT2 = "fine"
elseif __nuppT1 == "error" then __nuppT2 = "broken"
else __nuppT2 = "unrecognized"
end
return __nuppT2
endHandling "ok" and "error" leaves that member behind, so the switch keeps asking for its else and never reports one as unnecessary. Status prints as "error" | "ok" | ..., and it does not fit "ok" | "error", which is the same statement read from the other side: code outside the declaration may not assume the set is closed. Each alternative still assigns into it, so adding one is not a breaking change for the callers that write them.
The member is only ever obtained by calling for it. There is no name for it, in a case, an annotation, or anywhere else. See Comptime types for the rest of the type builders it belongs to.
Returning-branch exhaustiveness#
When a dispatch on a closed set of literals has every branch return, the checker reports the members you left out:
local function describe(c: Color): string
if c == "red" then
return "warm"
elseif c == "green" then
return "cool"
end
return "unknown"
endwarning: NUPP2107 exhaustiveness: every branch returns, so this handles
"blue" | "green" | "red" and leaves "blue" unhandled
help: add branches for "blue" or add an else clauseAdding the branch or an else clears it. The diagnostic gives help rather than an edit, because it cannot invent the body of the branch you are missing.
This is the exhaustiveness lint, a correctness lint at warning. A project raises it to error in nupp.lua, and a single deliberate exception writes @allow("exhaustiveness") on the statement.
Exhaustiveness counts single literal types and unions of them. It does not run over a union of records: a dispatch there tests a field rather than the value, and the checker does not count the arms.
Dive deeper
The two constructs get different severities because they promise different things. A switch expression has to produce a value on every path, so an unhandled member is a hole in the expression's type and nothing weaker than an error describes it. An if chain promises nothing: falling off the end is legal Lua, and a chain that deliberately handles two of five cases is an ordinary program. Reporting that as an error would make the closed union unusable outside a switch, so it is a lint a project raises when it wants the stronger rule everywhere.
Narrowing#
Comparing against a member narrows in both directions:
local function widthOf(c: Color): integer
if c == "red" then
-- c is "red" here
return 3
else
-- c is "green" | "blue" here
return 0
end
ende is T narrows a union whose members are distinguishable by type rather than by value:
local function render(v: string | integer): string
if v is string then
return v
end
return tostring(v)
endSee Narrowing for every test that narrows and the ones that look like they should and do not.
Choosing a union kind#
What the alternatives carry decides which of the two you want:
- The alternatives are values, and nothing rides along: a union of literals.
- The alternatives carry different data: a tagged union of records.
- The alternatives are unrelated existing types, told apart by
is: a plain union, no tag needed.