Skip to content

Commit 80cfc24

Browse files
committed
feat(types): add Never bottom type for narrowing exhaustiveness
Narrowing wants a way to say 'this case is unreachable.' Concrete example: a switch over a closed string-enum that handles every literal. After the last case, the residual type of the discriminant is the empty set - no value remains. Code at that point is provably unreachable, but it still needs to type-check (the user may write something there that we don't want to flag spuriously). Never is that empty type. It's the bottom of the lattice: no value inhabits it at runtime, so it's vacuously a subtype of everything. That property is what makes exhaustiveness composable - the residual type from peeling cases lands in Never naturally, and post-switch code typed against Never can flow into any subsequent slot. Two assignability rules earn their keep: - Never accepts only Never. A real value assigned into a slot typed Never means narrowing was unsound - we thought the branch was unreachable, but the user reached it. - Everything else accepts Never. Vacuously, because the source can never produce a value, so the target's constraints don't matter. Implemented via the same flows-into-anything fast path used for any and dynamic. Void is the exception: it accepts Never (vacuously) but still rejects any and dynamic, since the gradual-typing rule that lets typed slots silently accept any values would also let 'x = print(...)' slip through. Never doesn't break that guarantee because there are no Never values to slip. Users never write 'never' themselves; it appears only in inferred types and error messages.
1 parent 4363b5c commit 80cfc24

4 files changed

Lines changed: 91 additions & 10 deletions

File tree

rts/rl/consts.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ const (
212212
T_ERROR = "error"
213213
T_ANY = "any"
214214
T_DYNAMIC = "dynamic"
215+
T_NEVER = "never"
215216
T_VOID = "void"
216217
T_LIST = "list"
217218
T_MAP = "map"

rts/rl/typing.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ var (
8080
_ TypingT = (*TypingErrorT)(nil)
8181
_ TypingT = (*TypingAnyT)(nil)
8282
_ TypingT = (*TypingDynamicT)(nil)
83+
_ TypingT = (*TypingNeverT)(nil)
8384
_ TypingT = (*TypingVoidT)(nil)
8485
_ TypingT = (*TypingAnyListT)(nil)
8586
_ TypingT = (*TypingListT)(nil)
@@ -232,6 +233,35 @@ func (t *TypingDynamicT) IsCompatibleWith(TypingCompatVal) bool {
232233
return true
233234
}
234235

236+
// TypingNeverT is the bottom type. No value inhabits it at runtime; the
237+
// static checker synthesizes it when narrowing has eliminated every variant
238+
// of a type (e.g. a switch over a string-enum that handles every literal
239+
// leaves `Never` as the residual). Users never write `never` themselves.
240+
//
241+
// As a subtype of everything, Never is assignable into any slot - that's
242+
// what makes the "you exhausted the switch" property compose naturally with
243+
// the rest of the type checker. But nothing except Never is assignable TO
244+
// Never; assigning a real value into a Never-typed slot signals a soundness
245+
// issue (the checker thought a branch was unreachable, but the user reached
246+
// it anyway).
247+
type TypingNeverT struct{}
248+
249+
func NewNeverType() *TypingNeverT {
250+
return &TypingNeverT{}
251+
}
252+
253+
func (t *TypingNeverT) Name() string {
254+
return T_NEVER
255+
}
256+
257+
// IsCompatibleWith returns false: no value has type Never at runtime. This
258+
// matters when the static type happens to flow into a function call boundary
259+
// check - any actual value will fail the compatibility test, which is the
260+
// correct outcome since reaching such a call would mean narrowing was wrong.
261+
func (t *TypingNeverT) IsCompatibleWith(TypingCompatVal) bool {
262+
return false
263+
}
264+
235265
type TypingVoidT struct{} // -> void
236266

237267
func NewVoidType() *TypingVoidT {

rts/rl/typing_assignable.go

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,16 @@ package rl
88
// compatibility logic together; the runtime IsCompatibleWith methods remain
99
// alongside their types.
1010

11-
// isAnyLike reports whether other is one of the universally-consistent types:
12-
// `any` (user-written escape hatch) or `dynamic` (the implicit form assigned
13-
// when static inference can't pin a type). Every concrete IsAssignableFrom
14-
// checks this first so values of these types can flow into any target without
15-
// false negatives. The set grows again when the checker adds `error_type`
16-
// (poisoned, for cascade prevention).
11+
// isAnyLike reports whether other is a "flows-into-anything" type: `any`
12+
// (user-written escape hatch), `dynamic` (the implicit form assigned when
13+
// inference can't pin a type), or `never` (the bottom type, vacuously a
14+
// subtype of everything because no value inhabits it). Every concrete
15+
// IsAssignableFrom checks this first so values of these types can flow into
16+
// any target without false negatives. The set grows again when the checker
17+
// adds `error_type` (poisoned, for cascade prevention).
1718
func isAnyLike(other TypingT) bool {
1819
switch other.(type) {
19-
case *TypingAnyT, *TypingDynamicT:
20+
case *TypingAnyT, *TypingDynamicT, *TypingNeverT:
2021
return true
2122
}
2223
return false
@@ -54,6 +55,9 @@ func typesEqual(a, b TypingT) bool {
5455
case *TypingDynamicT:
5556
_, ok := b.(*TypingDynamicT)
5657
return ok
58+
case *TypingNeverT:
59+
_, ok := b.(*TypingNeverT)
60+
return ok
5761
case *TypingVoidT:
5862
_, ok := b.(*TypingVoidT)
5963
return ok
@@ -236,14 +240,27 @@ func (t *TypingDynamicT) IsAssignableFrom(TypingT) bool {
236240
return true
237241
}
238242

239-
// Void is the type of expressions that produce no value (e.g. print()). Only
240-
// itself is assignable to it; this catches `x = print(...)` at the type
241-
// checker rather than at runtime.
243+
// Void is the type of expressions that produce no value (e.g. print()).
244+
// Only Void itself and Never are assignable to it. Notably `any` and
245+
// `dynamic` are NOT - that's how `x = print(...)` gets caught instead of
246+
// being silently swallowed under gradual consistency.
242247
func (t *TypingVoidT) IsAssignableFrom(other TypingT) bool {
248+
if _, ok := other.(*TypingNeverT); ok {
249+
return true
250+
}
243251
_, ok := other.(*TypingVoidT)
244252
return ok
245253
}
246254

255+
// Never is the bottom type: only Never itself can flow into a Never slot.
256+
// (Other types DO accept Never as a source because Never is a vacuous
257+
// subtype of everything - that's handled by the isAnyLike short-circuit at
258+
// the top of every other IsAssignableFrom.)
259+
func (t *TypingNeverT) IsAssignableFrom(other TypingT) bool {
260+
_, ok := other.(*TypingNeverT)
261+
return ok
262+
}
263+
247264
// --- Collections (invariant) ---
248265

249266
// AnyList is the unparameterized list type. It accepts any concrete list or

rts/rl/typing_assignable_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,39 @@ func TestAssign_DynamicIsUniversallyConsistent(t *testing.T) {
7979
assert.True(t, strList.IsAssignableFrom(dynT))
8080
}
8181

82+
func TestAssign_NeverIsBottom(t *testing.T) {
83+
neverT := rl.NewNeverType()
84+
intT := rl.NewIntType()
85+
strList := rl.NewListType(rl.NewStrType())
86+
87+
// Never accepts only Never.
88+
assert.True(t, neverT.IsAssignableFrom(rl.NewNeverType()))
89+
assert.False(t, neverT.IsAssignableFrom(intT))
90+
assert.False(t, neverT.IsAssignableFrom(strList))
91+
assert.False(t, neverT.IsAssignableFrom(rl.NewVoidType()))
92+
93+
// Every other type accepts Never as a source - it's vacuously a subtype
94+
// of everything because no value inhabits it. This is what makes
95+
// "switch exhausted all cases, residual is Never, post-switch code is
96+
// reachable as anything" work cleanly.
97+
assert.True(t, intT.IsAssignableFrom(neverT))
98+
assert.True(t, strList.IsAssignableFrom(neverT))
99+
assert.True(t, rl.NewAnyType().IsAssignableFrom(neverT))
100+
101+
// Even void accepts Never (vacuously).
102+
assert.True(t, rl.NewVoidType().IsAssignableFrom(neverT))
103+
}
104+
105+
func TestAssign_NeverHasNoValues(t *testing.T) {
106+
// No runtime value should ever be considered compatible with Never. The
107+
// runtime never sees Never directly today, but the contract matters if
108+
// it ever flows through a call-boundary check.
109+
neverT := rl.NewNeverType()
110+
assert.False(t, neverT.IsCompatibleWith(rl.NewIntSubject(0)))
111+
assert.False(t, neverT.IsCompatibleWith(rl.NewStrSubject("")))
112+
assert.False(t, neverT.IsCompatibleWith(rl.NewNullSubject()))
113+
}
114+
82115
func TestAssign_DynamicAndAnyAreDistinct(t *testing.T) {
83116
// They behave identically for IsAssignableFrom today, but they're not the
84117
// same type. The static checker must be able to tell them apart - that's

0 commit comments

Comments
 (0)