Skip to content

Commit ee39b2f

Browse files
timfennisclaude
andcommitted
✨ Reject incompatible type reassignment and widen on compatible assignment
The analyser now checks that reassignments are type-compatible. Widening within a type family (e.g. Int → Number) is allowed and updates the binding type, but changing to a completely different type (e.g. () → (Int, Int)) is a static error. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4cdadc7 commit ee39b2f

4 files changed

Lines changed: 91 additions & 12 deletions

File tree

manual/src/reference/variables-and-scopes.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,31 @@ let x = {
4040
print(x); // 3
4141
```
4242

43+
## Reassignment
44+
45+
The `=` operator can be used to reassign a value to an existing variable. However, the new value must be compatible with the variable's original type. Widening within a type family is allowed (for example, assigning a float to a variable that held an integer), but changing to a completely different type is not.
46+
47+
```ndc
48+
let x = 1;
49+
x = 2; // ok: same type
50+
x = 3.14; // ok: Int widens to Number
51+
```
52+
53+
```ndc
54+
let pos = ();
55+
pos = (1, 2); // error: cannot assign Tuple<Int, Int> to a variable of type ()
56+
```
57+
58+
If you need a variable that will hold different types, initialize it with a value of the correct type or use `None` and `Some`:
59+
60+
```ndc
61+
let pos = (0, 0);
62+
pos = (1, 2); // ok: same tuple arity
63+
64+
let name = None;
65+
name = Some("Alice"); // ok: Option type
66+
```
67+
4368
## Destructuring
4469

4570
Destructuring is more similar to how it works in python and cares mostly about where commas are and not so much about the delimiters (`[]`, `()`) used.

ndc_analyser/src/analyser.rs

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,35 @@ impl Analyser {
7878
Ok(StaticType::unit())
7979
}
8080
Expression::Assignment { l_value, r_value } => {
81-
self.resolve_lvalue(l_value, *span)?;
82-
self.analyse(r_value)?;
81+
let old_type = self.resolve_lvalue(l_value, *span)?;
82+
let new_type = self.analyse(r_value)?;
83+
84+
if let Lvalue::Identifier {
85+
resolved: Some(target),
86+
..
87+
} = l_value
88+
{
89+
// Reject assignments that change the variable to an
90+
// incompatible type. Widening within a type family
91+
// (e.g. Int → Number) is allowed, but changing to a
92+
// completely different type (e.g. () → (Int, Int)) is not.
93+
let widened = old_type.lub(&new_type);
94+
if !matches!(old_type, StaticType::Any)
95+
&& !matches!(new_type, StaticType::Any)
96+
&& !old_type.same_kind(&widened)
97+
{
98+
return Err(AnalysisError::type_mismatch_in_assignment(
99+
&old_type, &new_type, *span,
100+
));
101+
}
102+
103+
// If the assignment widens the type, update the binding so
104+
// subsequent uses see the broader type.
105+
if widened != old_type {
106+
self.scope_tree.update_binding_type(*target, widened);
107+
}
108+
}
109+
83110
Ok(StaticType::unit())
84111
}
85112
Expression::OpAssignment {
@@ -615,6 +642,13 @@ impl AnalysisError {
615642
}
616643
}
617644

645+
fn type_mismatch_in_assignment(expected: &StaticType, actual: &StaticType, span: Span) -> Self {
646+
Self {
647+
text: format!("Cannot assign {actual} to a variable of type {expected}"),
648+
span,
649+
}
650+
}
651+
618652
fn function_not_found(ident: &str, types: &[StaticType], span: Span) -> Self {
619653
Self {
620654
text: format!(

ndc_core/src/static_type.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,32 @@ impl StaticType {
121121
/// - Generic types are covariant in their type parameters
122122
/// - Function parameters are **contravariant**, returns are **covariant**
123123
///
124+
/// Returns true if two types share the same "kind" — i.e. the same
125+
/// top-level type constructor (or both are numeric). This is used to
126+
/// decide whether a reassignment is a compatible widening vs. a type
127+
/// change that should be rejected.
128+
pub fn same_kind(&self, other: &Self) -> bool {
129+
use std::mem::discriminant;
130+
match (self, other) {
131+
// All numeric types are the same kind
132+
(s, t)
133+
if matches!(
134+
s,
135+
Self::Int | Self::Float | Self::Rational | Self::Complex | Self::Number
136+
) && matches!(
137+
t,
138+
Self::Int | Self::Float | Self::Rational | Self::Complex | Self::Number
139+
) =>
140+
{
141+
true
142+
}
143+
// Tuples must have the same arity to be the same kind
144+
(Self::Tuple(a), Self::Tuple(b)) => a.len() == b.len(),
145+
// Otherwise, same discriminant means same kind
146+
_ => discriminant(self) == discriminant(other),
147+
}
148+
}
149+
124150
/// Note: this function probably doesn't handle variadic functions correctly
125151
pub fn is_subtype(&self, other: &Self) -> bool {
126152
// Never is the bottom type: subtype of everything
Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,7 @@
1-
// Destructuring let where the variable was declared as () but later
2-
// reassigned to a 2-tuple should not cause a false "not declared" error.
3-
// expect-output: 4
1+
// Reassigning a variable to an incompatible type is a static error.
2+
// Previously this caused a confusing "not declared" error at the
3+
// destructuring site instead of flagging the real problem.
4+
// expect-error: Cannot assign Tuple<Int, Int> to a variable of type ()
45

56
let pos = ();
67
pos = (1, 3);
7-
8-
fn use_pos() {
9-
let a, b = pos;
10-
(a + b).print;
11-
}
12-
13-
use_pos();

0 commit comments

Comments
 (0)