Skip to content

Commit d437dfe

Browse files
committed
feat(check): catch block narrows target to error
Inside an assignment's catch block, reads of the assignment target now see type `error` rather than the post-success type the frame held coming out of walkAssign. The runtime guarantee is clear: a catch block executes precisely because the RHS errored - so within the catch body, the target IS carrying an error value. Previously the catch body walked under the frame walkAssign set up for the success path (the assignment target narrowed to the RHS value type), which silently said "the user referenced x inside the catch, and x is int" - incorrect, since the RHS errored on this path. The override is the error arm of the RHS type when extractable: parse_int returns int|error, so the override is `error` (the second arm). Falls back to a bare TypingErrorT when no error arm is visible (we couldn't introspect the RHS type, but the runtime already guarantees we're in the error branch - error is sound). After the catch body, tc.frame is restored. The catch's body narrowing doesn't leak into subsequent statements - exactly what the immutable frame's save/restore pattern gives us. extractErrorFrom lives next to stripErrorFrom in narrow.go (they're duals - one returns the error component, the other returns everything except). Shared code: future error-path narrowings (e.g. ExprStmt + catch in shell statements) can reuse the same helper.
1 parent ebe5242 commit d437dfe

2 files changed

Lines changed: 83 additions & 8 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
### TITLE ###
2+
CatchBlockNarrowsTargetToError
3+
### DESCRIPTION ###
4+
Inside an assignment's catch block, the target carries the error
5+
value the catch fired on. Reads of x within the catch body see
6+
type `error`; reads after the catch see the (potentially still
7+
error-typed) RHS result.
8+
### INPUT ###
9+
x = parse_int("foo") catch:
10+
print(x)
11+
yield 0
12+
y = x
13+
### STDOUT ###
14+
# Identifier types
15+
x @ 1:1 -> <no-type>
16+
parse_int @ 1:5 -> dynamic
17+
print @ 2:5 -> dynamic
18+
x @ 2:11 -> error
19+
y @ 4:1 -> <no-type>
20+
x @ 4:5 -> int|error
21+
22+
# Symbol types
23+
x (local): int|error
24+
y (local): int|error
25+
26+
# Diagnostics
27+
(none)
28+
### TITLE ###
29+
CatchBlockOnTypedLocalUsesDeclaredErrorArm
30+
### DESCRIPTION ###
31+
Typed local `x: int|error`. Inside the catch the override is the
32+
error arm specifically.
33+
### INPUT ###
34+
x: int|error = parse_int("hi") catch:
35+
print(x)
36+
yield 0
37+
### STDOUT ###
38+
# Identifier types
39+
x @ 1:1 -> <no-type>
40+
parse_int @ 1:16 -> dynamic
41+
print @ 2:5 -> dynamic
42+
x @ 2:11 -> error
43+
44+
# Symbol types
45+
x (local): int|error
46+
47+
# Diagnostics
48+
(none)

rts/check/type_check.go

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -729,16 +729,43 @@ func (tc *typeChecker) walkAssign(a *rl.Assign) {
729729
tc.info.SymbolTypes[sym] = valType
730730
tc.frame = tc.frame.With(sym, valType)
731731
}
732-
// Walk the catch block (if any) so hover and sub-expression type
733-
// info inside the catch are recorded. The catch executes when the
734-
// RHS errors, so identifier reads inside it could in principle
735-
// see the assignment target as type `error`; we don'\''t narrow
736-
// that yet because the AST doesn'\''t carry an explicit binding for
737-
// the caught error (the user references the assignment target
738-
// directly), and the runtime semantics aren'\''t covered by a
739-
// single-Symbol narrowing.
732+
// Walk the catch block (if any) with assignment targets narrowed
733+
// to their error component. The catch runs when the RHS errored,
734+
// so each target currently holds an error value - inside the
735+
// catch body, reads of the target should see `error`, not the
736+
// non-error narrowing walkAssign just installed in the frame.
737+
//
738+
// For a typed local `x: int|error = parse_int(...)` the override
739+
// is the error arm of the declared type. For an unannotated
740+
// local where the RHS synthed to int|error, we pick out the
741+
// error arm. Falls back to a bare TypingErrorT when we can'\''t
742+
// extract one (the runtime guarantee is "RHS errored," so error
743+
// is always sound).
740744
if a.Catch != nil {
745+
savedFrame := tc.frame
746+
overrides := make(map[*Symbol]rl.TypingT, len(a.Targets))
747+
for i, target := range a.Targets {
748+
ident, ok := target.(*rl.Identifier)
749+
if !ok {
750+
continue
751+
}
752+
sym, ok := tc.resolved.Uses[ident]
753+
if !ok || sym == nil {
754+
continue
755+
}
756+
var rhsType rl.TypingT
757+
if i < len(a.Values) {
758+
rhsType = tc.synth(a.Values[i])
759+
}
760+
errArm := extractErrorFrom(rhsType)
761+
if errArm == nil {
762+
errArm = rl.NewErrorType()
763+
}
764+
overrides[sym] = errArm
765+
}
766+
tc.frame = tc.frame.WithMany(overrides)
741767
tc.walkStmts(a.Catch.Stmts)
768+
tc.frame = savedFrame
742769
}
743770
}
744771

0 commit comments

Comments
 (0)