Skip to content

Commit d4ade01

Browse files
committed
feat(typing): introduce TypingNullT for sound null literal handling
Before this change, the `null` literal synthesized to Dynamic, which made `a: int = null` (and every other typed-slot-with-null) silently type-check. Reassignments to null left the narrowed type as Dynamic too, so subsequent reads didn't reflect "this is definitely null." Inferred returns mixing a value and null produced int|dynamic instead of int?. New leaf type TypingNullT: - LitNull synth uses TypingNullT instead of Dynamic. - TypingNullT only flows into slots that admit null - Optional<T>, unions containing null, any/dynamic. Non-nullable slots emit RAD30001 (the existing nullable-suggestion diagnostic). User- facing name is "null"; users never write it as a standalone annotation (T? remains the canonical spelling). - TypingOptionalT.IsAssignableFrom learns to accept TypingNullT directly (in addition to T and Optional<T>). Narrowing now has a definite answer on the null side: - narrowNullEquality returns TypingNullT on the "x == null" arm, replacing the previous no-op. Downstream type_of("null") dispatch and "subtract the non-null arm" patterns see x as definitely null. - narrowByTypeOf for Optional<T> returns TypingNullT on the null arm (truthy when target == "null", falsy when target matched the non-null inner type). - joinNarrowArms and unionTypesForJoin both collapse `T | null` to `T?` so two-arm narrowing results match how users spell nullable themselves (e.g. type_of-narrowing `int?|str` produces `str?` on the non-int branch instead of `null|str`). The existing matchesTypeOf("null") returned false unconditionally; now it returns true for TypingNullT leaves so the new narrowing arms compose with downstream type_of guards. Snapshots in narrow/null.snap: a: int = null fires, a: int? = null clean, else-of-!=null narrows to TypingNullT, and a fn mixing return 5 / return null synthesizes to int?. Drift in narrow/nested_fn.snap (x reassigned to null now types null, not dynamic) and narrow/type_of.snap (b in the else branch collapses to str?) is the expected canonicalisation.
1 parent 68a78e1 commit d4ade01

8 files changed

Lines changed: 222 additions & 69 deletions

File tree

rts/check/narrow.go

Lines changed: 40 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -232,14 +232,10 @@ func mergeRefinementMaps(a, b map[*Symbol]rl.TypingT) map[*Symbol]rl.TypingT {
232232
// narrowNullEquality is invoked after the dispatcher confirms the
233233
// shape is `<ident> ==/!= null` (in either operand order). It looks
234234
// up the symbol, peels the nullable component from the base type, and
235-
// records the non-null type on the appropriate branch.
236-
//
237-
// The "narrow x to null" side is intentionally a no-op. We have no
238-
// TypingNullT in the static system, and the practical payoff of "x is
239-
// null in this branch" is small (users rarely access members of a
240-
// definitely-null value). Adding TypingNullT later is a pure expansion:
241-
// existing scripts keep working, the null branch just gains a tighter
242-
// static answer.
235+
// records both the non-null branch and the definitely-null branch.
236+
// With TypingNullT in the system, the previously-no-op null side now
237+
// narrows to TypingNullT - downstream `type_of(x) == "null"` dispatch
238+
// and similar patterns see x as definitely null in the matching arm.
243239
func (tc *typeChecker) narrowNullEquality(op rl.Operator, ident *rl.Identifier, frame *Frame) Refinement {
244240
sym, ok := tc.resolved.Uses[ident]
245241
if !ok || sym == nil {
@@ -254,11 +250,12 @@ func (tc *typeChecker) narrowNullEquality(op rl.Operator, ident *rl.Identifier,
254250
return EmptyRefinement()
255251
}
256252
nonNullBranch := map[*Symbol]rl.TypingT{sym: nonNull}
253+
nullBranch := map[*Symbol]rl.TypingT{sym: rl.NewNullType()}
257254
switch op {
258255
case rl.OpEq:
259-
return Refinement{WhenTrue: map[*Symbol]rl.TypingT{}, WhenFalse: nonNullBranch}
256+
return Refinement{WhenTrue: nullBranch, WhenFalse: nonNullBranch}
260257
case rl.OpNeq:
261-
return Refinement{WhenTrue: nonNullBranch, WhenFalse: map[*Symbol]rl.TypingT{}}
258+
return Refinement{WhenTrue: nonNullBranch, WhenFalse: nullBranch}
262259
}
263260
return EmptyRefinement()
264261
}
@@ -593,12 +590,8 @@ func matchesTypeOf(t rl.TypingT, target string) bool {
593590
_, ok := t.(*rl.TypingFnT)
594591
return ok
595592
case "null":
596-
// No TypingNullT today; the null component is implicit in
597-
// TypingOptionalT and not directly representable as a leaf.
598-
// narrowByTypeOf handles the optional case explicitly so this
599-
// branch is only hit for non-nullable leaves, where the answer
600-
// is unambiguously false.
601-
return false
593+
_, ok := t.(*rl.TypingNullT)
594+
return ok
602595
}
603596
return false
604597
}
@@ -619,32 +612,22 @@ func matchesTypeOf(t rl.TypingT, target string) bool {
619612
// this arm means we keep the whole arm as a fallback.
620613
// - Optional<T>:
621614
// target == "null":
622-
// truthy: x is null. We have no TypingNullT, so we return
623-
// the original Optional<T> as the conservative
624-
// over-approximation - the value is still typed as
625-
// nullable, and the union-walk caller can rely on
626-
// the arm not being silently dropped.
615+
// truthy: TypingNullT (definite - the null arm matched).
627616
// falsy: x is non-null - return T.
628617
// inner matches target:
629618
// truthy: T (the non-null component).
630-
// falsy: x is null - return Optional<T> conservatively
631-
// (no TypingNullT). This preserves the null arm
632-
// when the optional sits inside a union and the
633-
// other arms don'\''t fold it back in.
619+
// falsy: TypingNullT (only possibility left).
634620
// inner doesn'\''t match target:
635621
// truthy: Never (inner doesn'\''t match, null doesn'\''t
636622
// match any non-null target).
637623
// falsy: the original Optional<T> stays.
638-
// - Leaf (non-Optional):
624+
// - TypingNullT (definite-null leaf):
625+
// target == "null": truthy=null, falsy=Never.
626+
// any other target: truthy=Never, falsy=null.
627+
// - Leaf (non-Optional, non-null):
639628
// target == "null": truthy=Never, falsy=base.
640629
// matches: truthy=base, falsy=Never.
641-
// doesn'\''t match: truthy=Never, falsy=base.
642-
//
643-
// The shift from the previous behavior: Optional cases now return
644-
// the original Optional<T> rather than nil on the side that has
645-
// "the null half is still possible." This stops union arm walks
646-
// from silently dropping the null arm when a non-null target
647-
// matched a different union arm.
630+
// doesn'\''t match: truthy=Never, falsy=base.
648631
func narrowByTypeOf(base rl.TypingT, target string) (truthy, falsy rl.TypingT) {
649632
if base == nil {
650633
return nil, nil
@@ -677,22 +660,25 @@ func narrowByTypeOf(base rl.TypingT, target string) (truthy, falsy rl.TypingT) {
677660
if o, ok := base.(*rl.TypingOptionalT); ok {
678661
inner := o.Inner()
679662
if target == "null" {
680-
// Truthy: x IS null. No TypingNullT, so keep the optional
681-
// as the conservative "value is still nullable" answer.
663+
// Truthy: x IS null - return TypingNullT (definite).
682664
// Falsy: x is non-null = the inner type.
683-
return base, inner
665+
return rl.NewNullType(), inner
684666
}
685667
if matchesTypeOf(inner, target) {
686668
// Truthy: non-null inner matched. Falsy: only possibility
687-
// left is null - keep Optional<T> as the conservative
688-
// "still nullable" answer; this preserves the null arm
689-
// when used inside a union walk.
690-
return inner, base
669+
// left is null.
670+
return inner, rl.NewNullType()
691671
}
692672
// Inner doesn'\''t match target and null doesn'\''t match any
693673
// non-null target. Truthy is empty; falsy is the original.
694674
return rl.NewNeverType(), base
695675
}
676+
if _, ok := base.(*rl.TypingNullT); ok {
677+
if target == "null" {
678+
return base, rl.NewNeverType()
679+
}
680+
return rl.NewNeverType(), base
681+
}
696682
if target == "null" {
697683
return rl.NewNeverType(), base
698684
}
@@ -712,13 +698,26 @@ func isNeverType(t rl.TypingT) bool {
712698

713699
// joinNarrowArms collapses a slice of narrowed arms into a single
714700
// TypingT: empty -> Never (no remaining arms means the branch is
715-
// unreachable), one arm -> that arm, more -> a union.
701+
// unreachable), one arm -> that arm, more -> a union. Two-arm unions
702+
// with a null component collapse to T? to match how users spell
703+
// nullable types and how unionTypesForJoin canonicalises inferred
704+
// returns.
716705
func joinNarrowArms(arms []rl.TypingT) rl.TypingT {
717706
switch len(arms) {
718707
case 0:
719708
return rl.NewNeverType()
720709
case 1:
721710
return arms[0]
711+
case 2:
712+
if _, leftNull := arms[0].(*rl.TypingNullT); leftNull {
713+
if _, rightNull := arms[1].(*rl.TypingNullT); !rightNull {
714+
return rl.NewOptionalType(arms[1])
715+
}
716+
}
717+
if _, rightNull := arms[1].(*rl.TypingNullT); rightNull {
718+
return rl.NewOptionalType(arms[0])
719+
}
720+
return rl.NewUnionType(arms...)
722721
default:
723722
return rl.NewUnionType(arms...)
724723
}

rts/check/narrow_interp_test.go

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -64,27 +64,32 @@ func makeChecker(baseType rl.TypingT) (*typeChecker, *rl.Identifier, *Symbol) {
6464

6565
func TestInterpretCondition_NeqNullNarrowsTruthyToNonNull(t *testing.T) {
6666
// `x != null` where x: int?
67-
// Truthy branch should narrow x to int.
67+
// Truthy branch narrows x to int; falsy branch narrows x to null.
6868
tc, ident, sym := makeChecker(rl.NewOptionalType(rl.NewIntType()))
6969
cond := rl.NewOpBinary(rl.Span{}, rl.OpNeq, ident, rl.NewLitNull(rl.Span{}))
7070
r := tc.interpretCondition(cond, nil)
7171

7272
got, ok := r.WhenTrue[sym]
73-
require.True(t, ok, "truthy branch should narrow x")
73+
require.True(t, ok, "truthy branch should narrow x to non-null")
7474
assert.Equal(t, rl.T_INT, got.Name())
75-
assert.Empty(t, r.WhenFalse, "falsy branch should record no narrowing (null side)")
75+
nullGot, ok := r.WhenFalse[sym]
76+
require.True(t, ok, "falsy branch should narrow x to null")
77+
assert.Equal(t, "null", nullGot.Name())
7678
}
7779

7880
func TestInterpretCondition_EqNullNarrowsFalsyToNonNull(t *testing.T) {
79-
// `x == null` where x: str? - inverse of the != case.
81+
// `x == null` where x: str?
82+
// Truthy narrows x to null; falsy narrows x to str.
8083
tc, ident, sym := makeChecker(rl.NewOptionalType(rl.NewStrType()))
8184
cond := rl.NewOpBinary(rl.Span{}, rl.OpEq, ident, rl.NewLitNull(rl.Span{}))
8285
r := tc.interpretCondition(cond, nil)
8386

8487
got, ok := r.WhenFalse[sym]
8588
require.True(t, ok, "falsy branch should narrow x to non-null")
8689
assert.Equal(t, rl.T_STR, got.Name())
87-
assert.Empty(t, r.WhenTrue, "truthy branch (null side) should record no narrowing")
90+
nullGot, ok := r.WhenTrue[sym]
91+
require.True(t, ok, "truthy branch should narrow x to null")
92+
assert.Equal(t, "null", nullGot.Name())
8893
}
8994

9095
func TestInterpretCondition_SwappedOperandsStillNarrows(t *testing.T) {
@@ -158,25 +163,24 @@ func TestNarrowByTypeOf_LeafNoMatch(t *testing.T) {
158163
}
159164

160165
func TestNarrowByTypeOf_OptionalInnerMatches(t *testing.T) {
161-
// Optional<int>, target="int" => truthy: int, falsy: int? (the
162-
// conservative "value is still nullable" approximation since we
163-
// have no TypingNullT to represent "definitely null").
166+
// Optional<int>, target="int" => truthy: int, falsy: null
167+
// (TypingNullT - definite, the only remaining possibility).
164168
base := rl.NewOptionalType(rl.NewIntType())
165169
truthy, falsy := narrowByTypeOf(base, "int")
166170
require.NotNil(t, truthy)
167171
assert.Equal(t, rl.T_INT, truthy.Name())
168-
require.NotNil(t, falsy, "null half stays as Optional, not nil")
169-
assert.Equal(t, "int?", falsy.Name(),
170-
"falsy keeps the optional - the only remaining possibility is null")
172+
require.NotNil(t, falsy)
173+
assert.Equal(t, "null", falsy.Name(),
174+
"falsy is definite null after the inner-int branch is taken")
171175
}
172176

173177
func TestNarrowByTypeOf_OptionalNullTarget(t *testing.T) {
174-
// Optional<int>, target="null" => truthy: int? (no TypingNullT,
175-
// keep the optional), falsy: int.
178+
// Optional<int>, target="null" => truthy: null (definite),
179+
// falsy: int.
176180
base := rl.NewOptionalType(rl.NewIntType())
177181
truthy, falsy := narrowByTypeOf(base, "null")
178-
require.NotNil(t, truthy, "truthy keeps the optional - null half")
179-
assert.Equal(t, "int?", truthy.Name())
182+
require.NotNil(t, truthy, "truthy is the definite-null arm")
183+
assert.Equal(t, "null", truthy.Name())
180184
require.NotNil(t, falsy)
181185
assert.Equal(t, rl.T_INT, falsy.Name())
182186
}

rts/check/snapshots/narrow/nested_fn.snap

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,17 +42,17 @@ fn f(x: int?):
4242
x @ 2:8 -> int?
4343
x @ 4:13 -> <no-type>
4444
y @ 5:13 -> <no-type>
45-
x @ 5:17 -> dynamic
45+
x @ 5:17 -> null
4646
after_inner @ 6:9 -> <no-type>
4747
x @ 6:23 -> int
4848

4949
# Symbol types
5050
after_inner (local): int
5151
f (fn): fn(int?) -> void
5252
g (fn): fn() -> void
53-
x (local): dynamic
53+
x (local): null
5454
x (param): int?
55-
y (local): dynamic
55+
y (local): null
5656

5757
# Diagnostics
5858
(none)

rts/check/snapshots/narrow/null.snap

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,85 @@ fn f(x: int):
7474

7575
# Diagnostics
7676
(none)
77+
### TITLE ###
78+
NullLiteralRejectedByNonNullableSlot
79+
### DESCRIPTION ###
80+
The null literal synthesizes to TypingNullT, which is only
81+
assignable to slots that admit null. A bare `a: int = null`
82+
fires a type-mismatch diagnostic.
83+
### INPUT ###
84+
a: int = null
85+
### STDOUT ###
86+
# Identifier types
87+
a @ 1:1 -> <no-type>
88+
89+
# Symbol types
90+
a (local): int
91+
92+
# Diagnostics
93+
[hint] RAD30001 @ 1:10 - Value of type 'null' is not assignable to declared type 'int'
94+
### TITLE ###
95+
NullLiteralAcceptedByOptionalSlot
96+
### DESCRIPTION ###
97+
TypingNullT flows freely into T? slots.
98+
### INPUT ###
99+
a: int? = null
100+
### STDOUT ###
101+
# Identifier types
102+
a @ 1:1 -> <no-type>
103+
104+
# Symbol types
105+
a (local): int?
106+
107+
# Diagnostics
108+
(none)
109+
### TITLE ###
110+
NotNullElseNarrowsToNull
111+
### DESCRIPTION ###
112+
The else branch of `if x != null:` narrows x to TypingNullT
113+
(definite null). Regression lock against the previous no-op,
114+
which left x typed as the un-narrowed `int?`.
115+
### INPUT ###
116+
fn f(x: int?):
117+
if x != null:
118+
a = x
119+
else:
120+
b = x
121+
### STDOUT ###
122+
# Identifier types
123+
x @ 2:8 -> int?
124+
a @ 3:9 -> <no-type>
125+
x @ 3:13 -> int
126+
b @ 5:9 -> <no-type>
127+
x @ 5:13 -> null
128+
129+
# Symbol types
130+
a (local): int
131+
b (local): null
132+
f (fn): fn(int?) -> void
133+
x (param): int?
134+
135+
# Diagnostics
136+
(none)
137+
### TITLE ###
138+
InferredReturnMixingValueAndNullProducesOptional
139+
### DESCRIPTION ###
140+
A fn that returns 5 in one branch and null in another should
141+
synthesize to `int?`, the canonical nullable form. Previously
142+
this produced `int|dynamic` because null synth fell back to
143+
Dynamic.
144+
### INPUT ###
145+
fn maybe(b: bool):
146+
if b:
147+
return 5
148+
return null
149+
### STDOUT ###
150+
# Identifier types
151+
b @ 2:8 -> bool
152+
153+
# Symbol types
154+
b (param): bool
155+
maybe (fn): fn(bool) -> int?
156+
157+
# Diagnostics
158+
(none)

rts/check/snapshots/narrow/type_of.snap

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,11 @@ fn f(x: int?|str):
4545
a @ 3:9 -> <no-type>
4646
x @ 3:13 -> int
4747
b @ 5:9 -> <no-type>
48-
x @ 5:13 -> int?|str
48+
x @ 5:13 -> str?
4949

5050
# Symbol types
5151
a (local): int
52-
b (local): int?|str
52+
b (local): str?
5353
f (fn): fn(int?|str) -> void
5454
x (param): int?|str
5555

rts/check/type_check.go

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1558,6 +1558,21 @@ func unionTypesForJoin(types []rl.TypingT) rl.TypingT {
15581558
return rl.NewNeverType()
15591559
case 1:
15601560
return flat[0]
1561+
case 2:
1562+
// `T | null` is the canonical nullable shape - collapse it to
1563+
// `T?` (TypingOptionalT) so inferred returns and narrowing
1564+
// joins read the way users spell nullable themselves. Multi-
1565+
// arm unions with null (e.g. `int|str|null`) stay as a union
1566+
// since `T|U?` is ambiguous to read.
1567+
if _, leftNull := flat[0].(*rl.TypingNullT); leftNull {
1568+
if _, rightNull := flat[1].(*rl.TypingNullT); !rightNull {
1569+
return rl.NewOptionalType(flat[1])
1570+
}
1571+
}
1572+
if _, rightNull := flat[1].(*rl.TypingNullT); rightNull {
1573+
return rl.NewOptionalType(flat[0])
1574+
}
1575+
return rl.NewUnionType(flat...)
15611576
default:
15621577
return rl.NewUnionType(flat...)
15631578
}
@@ -2036,11 +2051,12 @@ func (tc *typeChecker) synth(n rl.Node) rl.TypingT {
20362051
case *rl.LitBool:
20372052
return tc.record(v, rl.NewBoolType())
20382053
case *rl.LitNull:
2039-
// Rad models nullability via Optional<T>; a bare null literal
2040-
// without context is best-typed as Dynamic until later
2041-
// sub-commits give us a way to bubble the expected type into
2042-
// synth (the "check" direction of bidirectional checking).
2043-
return tc.record(v, rl.NewDynamicType())
2054+
// Rad's `null` literal has a dedicated static type so it
2055+
// only flows into slots that admit null (Optional<T>, unions
2056+
// containing null, any/dynamic). Synthesizing Dynamic here
2057+
// would silently fit any typed slot - a soundness gap that
2058+
// let `a: int = null` slip through.
2059+
return tc.record(v, rl.NewNullType())
20442060
case *rl.Identifier:
20452061
return tc.synthIdentifier(v)
20462062
case *rl.Call:

0 commit comments

Comments
 (0)