Subtyping Rules¶
Typist's subtype relation determines when a value of one type can be used where another type is expected. It is the foundation of type checking: every assignment, function call, and return statement is validated against these rules.
This page documents the complete subtype relation. Understanding it is useful for diagnosing type errors, designing type hierarchies, and working with advanced features like unions, records, and quantified types.
Notation¶
Throughout this page, A <: B means "A is a subtype of B" -- a value of type A can be used wherever type B is expected. A </: B means the subtype relation does not hold.
Atom Hierarchy¶
The built-in primitive types form a linear chain plus two independent branches:
| Rule | Example |
|---|---|
Bool <: Int |
A boolean is an integer |
Int <: Double |
An integer is a double |
Double <: Num |
A double is numeric |
Num <: Any |
All numerics are Any |
Str <: Any |
Strings are Any |
Undef <: Any |
Undef is Any |
Void <: Any |
Void subtypes only Any |
Str and Num are independent -- neither subtypes the other. Str </: Num and Num </: Str.
Universal Rules¶
These rules apply regardless of type constructor.
Identity¶
Every type is a subtype of itself.
Top¶
Any is the top type. Every type subtypes Any.
Bottom¶
Never is the bottom type. It subtypes everything. No value inhabits Never -- it represents unreachable code (e.g., a function that always throws).
Void¶
Void indicates no meaningful return value. It subtypes Any but nothing else. Void </: Int, Void </: Str, etc.
Literal Types¶
Literal types represent specific values: "hello" (a literal Str), 42 (a literal Int), true (a literal Bool).
Literal to Atom¶
A literal subtypes its base atom type. Literal(42, Int) <: Int.
Literal to Literal¶
Two literals are in a subtype relationship only if they have the same value and the base types are compatible. Literal(1, Bool) <: Literal(1, Int) because Bool <: Int and the value is the same.
Atom to Literal¶
A non-literal type never subtypes a literal type. Int </: Literal(42, Int) -- the set of all integers is not a subset of the singleton {42}.
Type Aliases¶
Type aliases (typedef) are transparent. The alias is resolved before comparison:
BEGIN { typedef Name => 'Str'; }
# Name <: Str and Str <: Name (they are the same type after resolution)
Resolution uses the Registry and handles chains: if A aliases B which aliases C, comparing A <: X first resolves A to C, then compares C <: X.
Union Types¶
A union T | U represents values that belong to either T or U.
Union as subtype¶
Every member of the union must subtype the target. This is the safe direction -- if all members fit, the union fits.
Union as supertype¶
It suffices for the value to fit any one member of the union.
Intersection Types¶
An intersection T & U represents values that belong to both T and U.
Intersection as subtype¶
If the value satisfies both T and U, it certainly satisfies either one individually.
Intersection as supertype¶
To be a subtype of an intersection, the value must satisfy all members.
Parameterized Types¶
Parameterized types like ArrayRef[T] and HashRef[K, V] are covariant in all parameters:
Examples:
# ArrayRef[Int] <: ArrayRef[Num] (because Int <: Num)
# ArrayRef[Num] </: ArrayRef[Int] (Num is not a subtype of Int)
# HashRef[Str, Int] <: HashRef[Str, Num]
Two parameterized types with different base constructors are never in a subtype relationship:
Note on soundness: Covariance for mutable containers is technically unsound (e.g., an
ArrayRef[Int]used asArrayRef[Num]could have aDoublepushed into it). Typist accepts this trade-off for practical usability, following the same approach as TypeScript.
Function Types¶
Function types follow the standard variance rules from type theory.
Parameters: contravariant¶
A function that accepts a broader input type is substitutable for one that accepts a narrower type. The parameter position is contravariant (the direction reverses).
# (Num) -> Str <: (Int) -> Str
# A function accepting any Num can be used where one accepting only Int is expected.
Return type: covariant¶
A function that returns a narrower type is substitutable for one that returns a broader type.
# (Int) -> Int <: (Int) -> Num
# A function returning Int can be used where one returning Num is expected.
Effects: covariant¶
A function with fewer effects can be used where more effects are permitted. Effect subtyping is based on label set inclusion (see Row Subtyping below).
Arity: strict¶
Function types must have the same number of parameters. (Int) -> Str </: (Int, Int) -> Str.
Record Types¶
Records are structural types with named fields. They support both width and depth subtyping.
Width subtyping (more fields subtypes fewer)¶
A record with more fields can be used where fewer fields are expected. The extra fields are simply ignored.
Depth subtyping (covariant field types)¶
Each field type is compared covariantly.
Optional field rules¶
A required field satisfies an optional field requirement. The converse does not hold -- an optional field cannot satisfy a required field, because the value might be missing.
When the super record has an optional field: - If the sub record has it (required or optional), the field types must be compatible. - If the sub record omits it entirely, that is acceptable (the field is optional after all).
Record to HashRef¶
A record with homogeneous value types subtypes the corresponding HashRef. This enables passing record literals where a HashRef is expected.
Struct Types (Nominal)¶
Structs use nominal subtyping -- two structs are in a subtype relationship only if they have the same name.
BEGIN {
struct Point => (x => 'Int', y => 'Int');
struct Vector => (x => 'Int', y => 'Int');
}
# Point </: Vector (different names, even though identical fields)
# Point <: Point (same name)
Struct subtypes Record (structural escape hatch)¶
A struct is a subtype of its structural record shape:
This allows functions that accept a record shape to work with any struct that has the right fields.
Record does not subtype Struct (nominal barrier)¶
A plain hashref with the right shape cannot masquerade as a named struct. This is the nominal guarantee -- if a function expects a Point, only a Point constructor call will do.
Generic struct covariance¶
Generic structs are covariant in their type arguments:
Newtype and Data Types (Nominal)¶
Like structs, newtypes and data types use nominal identity:
BEGIN {
newtype UserId => 'Int';
newtype GroupId => 'Int';
}
# UserId </: GroupId (different names)
# UserId <: UserId (same name)
# UserId </: Int (newtype is not its underlying type)
# Int </: UserId (no implicit wrapping)
Data types (from datatype) follow the same rule, with covariant type arguments when present:
BEGIN {
datatype 'Maybe[T]' => +{ Some => '(T)', None => '()' };
}
# Maybe[Int] <: Maybe[Num] (covariant)
Quantified Types (forall)¶
See Rank-2 Polymorphism for full coverage. The key subtyping rules:
Instantiation¶
A universally quantified type can be instantiated to match a concrete type. The checker uses structural unification to find a valid binding for the quantified variables.
Anti-rule¶
A concrete type cannot satisfy a universal requirement.
Subsumption¶
Two quantified types are compared by renaming variables and checking the bodies. Bounds are checked contravariantly.
Row Subtyping¶
Effect rows use label set inclusion:
A row with more labels subtypes a row with fewer labels. This means a function that performs effects {A, B, C} can be used where {A, B} is expected -- it simply performs more effects, which is safe because the handler for the larger set necessarily handles all the smaller set's labels.
Eff types (the ![...] annotation) delegate to their inner row:
Complete Rule Table¶
| # | Rule | Notation | Description |
|---|---|---|---|
| 1 | Identity | T <: T |
Every type is a subtype of itself |
| 2 | Top | T <: Any |
Every type subtypes Any |
| 3 | Bottom | Never <: T |
Never subtypes everything |
| 4 | Void | Void <: Any only |
Void only subtypes Any |
| 5 | Atom chain | Bool <: Int <: Double <: Num <: Any |
Numeric hierarchy |
| 6 | Atom independent | Str <: Any |
Str is independent from numerics |
| 7 | Literal-Atom | Literal(v, B) <: B |
Literals subtype their base |
| 8 | Literal-Literal | L1 <: L2 iff val= && base<: |
Same value, base subtypes |
| 9 | Alias | resolve then compare | Transparent resolution |
| 10 | Union (sub) | T|U <: S iff T<:S AND U<:S |
All members must subtype |
| 11 | Union (super) | S <: T|U iff S<:T OR S<:U |
Any member suffices |
| 12 | Intersection (sub) | T&U <: S iff T<:S OR U<:S |
Any member suffices |
| 13 | Intersection (super) | S <: T&U iff S<:T AND S<:U |
All must subtype |
| 14 | Param covariant | P[A] <: P[B] iff A<:B |
Same base, covariant args |
| 15 | Func params | (A)->R <: (B)->R iff B<:A |
Contravariant parameters |
| 16 | Func return | (A)->R <: (A)->S iff R<:S |
Covariant return |
| 17 | Func effects | !E1 <: !E2 iff E1<:E2 |
Covariant effects |
| 18 | Record width | {a,b,c} <: {a,b} |
More fields subtypes fewer |
| 19 | Record depth | {a:T} <: {a:U} iff T<:U |
Field covariance |
| 20 | Record optional | k:T <: k?:T |
Required subtypes optional |
| 21 | Struct nominal | S <: S iff same name |
Name identity |
| 22 | Struct generic | S[A] <: S[B] iff A<:B |
Covariant type args |
| 23 | Struct-Record | S <: {fields} |
Structural compatibility |
| 24 | Record-Struct | {fields} </: S |
Nominal barrier |
| 25 | Newtype | N <: N iff same name |
Name identity |
| 26 | Data | D <: D iff same name |
Name identity + covariant args |
| 27 | Quantified inst. | (forall A. T) <: U |
Instantiation |
| 28 | Quantified anti | U </: (forall A. T) |
Mono cannot satisfy forall |
| 29 | Quantified sub. | (forall A. T) <: (forall B. U) |
Rename and compare bodies |
| 30 | Row | Row(A,B) <: Row(A) |
Label set inclusion |
| 31 | Record-HashRef | {a:T,b:T} <: HashRef[Str,T] |
Homogeneous values |
Key Design Principles¶
Nominal vs structural. Structs, newtypes, and data types are nominal -- identity comes from their name, not their shape. Records, unions, intersections, and parameterized types are structural -- identity comes from their shape. This dual system lets you choose the right tool: nominal types for domain boundaries (a UserId is not a GroupId), structural types for data flow (any {name: Str} will do).
Contravariant function parameters. This is the standard type-theoretic rule and it may feel counterintuitive at first. Think of it this way: a function that accepts Num is more permissive than one that accepts Int, so it can substitute for the Int-accepting function -- not the other way around.
Covariant everything else. Return types, parameterized type arguments, effect rows, and record fields are all covariant. More specific subtypes less specific.
Gradual compatibility. When a type cannot be resolved (unknown alias, unregistered type), the checker falls back to permissive behavior rather than hard errors. This is consistent with Typist's gradual typing philosophy.