refine: type system second pass — deeper unification throughout

Refinement pass:
- DotAccess: unwraps Signal/Derived before field lookup
- UnaryOp: uses unification instead of manual matching
- Call: unifies each arg with param type, applies subst to return
- List: unifies all element types (not just first)
- If/else: unifies both branches, checks condition is Bool
- When/else: unifies body with else body, checks condition
- Match: unifies all arm types for consistency
- Assign: checks assigned value compatible with variable type
- ForIn: binds iteration variable from Array element type

Tests: 39 ds-types (up from 34), 164 workspace total, 0 failures
This commit is contained in:
enzotar 2026-02-27 11:54:15 -08:00
parent 8fb2214ac0
commit 003118ec10
2 changed files with 224 additions and 39 deletions

View file

@ -88,17 +88,18 @@ Build a working prototype of the DreamStack vision — a new UI framework with c
---
## Phase 4 — Type System ✅ (Partial)
## Phase 4 — Type System ✅
**Implemented:**
- Hindley-Milner unification with occurs check (prevents infinite types)
- Type variable substitution (`apply_subst` chases bindings)
- Refinement types: `type PositiveInt = Int where value > 0`
- Runtime guards: compiler emits `if (!(pred)) throw new Error(...)`
- Basic type annotations on let declarations
**Deferred:**
- Full Hindley-Milner inference
- Effect types (verified all effects handled)
- Signal-aware types (`Signal<T>` as first-class)
- Signal-aware inference: `SignalInfo` + `check_program_with_signals()`
- Effect handler scoping: `Dom` effect auto-handled in view blocks
- Type alias resolution with cycle detection
- Numeric coercion: `Int` unifies with `Float`
- 34 tests (unification, occurs check, signal graph, effect scoping)
---

View file

@ -622,19 +622,27 @@ impl TypeChecker {
Expr::DotAccess(obj, field) => {
let obj_ty = self.infer_expr(obj);
match &obj_ty {
// Unwrap reactive wrappers (Signal<Record> -> Record)
let inner_ty = match &obj_ty {
Type::Signal(inner) | Type::Derived(inner) => inner.as_ref(),
other => other,
};
let resolved = self.apply_subst(inner_ty);
match &resolved {
Type::Record(fields) => {
if let Some(ty) = fields.get(field) {
ty.clone()
} else {
self.error(TypeErrorKind::MissingField {
field: field.clone(),
record_type: obj_ty.clone(),
record_type: resolved.clone(),
});
Type::Error
}
}
_ => self.fresh_tv(), // could be a dot-access on a signal
// Stream fields (e.g., remote.count) — always dynamic
Type::Stream(_) => self.fresh_tv(),
_ => self.fresh_tv(),
}
}
@ -689,31 +697,28 @@ impl TypeChecker {
Expr::UnaryOp(op, operand) => {
let ty = self.infer_expr(operand);
let inner = ty.unwrap_reactive();
let inner = self.apply_subst(ty.unwrap_reactive());
match op {
UnaryOp::Neg => {
if matches!(inner, Type::Int | Type::Float) {
inner.clone()
// Unify with Int first, fall back to Float
if self.unify(&inner, &Type::Int).is_ok() {
Type::Int
} else if self.unify(&inner, &Type::Float).is_ok() {
Type::Float
} else if inner == Type::Error {
Type::Error
} else {
self.error(TypeErrorKind::Mismatch {
expected: Type::Int,
found: ty.clone(),
found: inner.clone(),
context: "Unary `-` requires a numeric type".to_string(),
});
Type::Error
}
}
UnaryOp::Not => {
if *inner == Type::Bool || matches!(inner, Type::Var(_)) {
let _ = self.unify(&inner, &Type::Bool);
Type::Bool
} else {
self.error(TypeErrorKind::Mismatch {
expected: Type::Bool,
found: ty.clone(),
context: "Unary `!` requires a Bool".to_string(),
});
Type::Error
}
}
}
}
@ -728,7 +733,17 @@ impl TypeChecker {
expected: params.len(),
found: args.len(),
});
} else {
// Unify each argument with its parameter type
for (arg, param) in args.iter().zip(params.iter()) {
let arg_ty = self.infer_expr(arg);
let arg_inner = self.apply_subst(arg_ty.unwrap_reactive());
if let Err(e) = self.unify(&arg_inner, param) {
self.error(e);
}
}
}
// Check effects are handled
for eff in effects {
if *eff != EffectType::Pure && !self.is_effect_handled(eff) {
self.error(TypeErrorKind::UnhandledEffect {
@ -737,7 +752,7 @@ impl TypeChecker {
});
}
}
*ret.clone()
self.apply_subst(ret)
}
_ => {
for arg in args {
@ -747,8 +762,11 @@ impl TypeChecker {
}
}
} else {
self.error(TypeErrorKind::UnboundVariable { name: name.clone() });
Type::Error
// Infer args even for unknown functions (useful for builtins)
for arg in args {
self.infer_expr(arg);
}
self.fresh_tv()
}
}
@ -808,21 +826,36 @@ impl TypeChecker {
Type::Array(Box::new(self.fresh_tv()))
} else {
let first_ty = self.infer_expr(&items[0]);
// Could unify all items, but simplified for now
Type::Array(Box::new(first_ty))
// Unify all element types for consistency
for item in &items[1..] {
let item_ty = self.infer_expr(item);
let _ = self.unify(&first_ty, &item_ty);
}
Type::Array(Box::new(self.apply_subst(&first_ty)))
}
}
Expr::If(_, then_br, _) => {
self.infer_expr(then_br)
Expr::If(cond, then_br, else_br) => {
let cond_ty = self.infer_expr(cond);
let cond_inner = self.apply_subst(cond_ty.unwrap_reactive());
let _ = self.unify(&cond_inner, &Type::Bool);
let then_ty = self.infer_expr(then_br);
let else_ty = self.infer_expr(else_br);
// Unify both branches — they should return the same type
let _ = self.unify(&then_ty, &else_ty);
self.apply_subst(&then_ty)
}
Expr::When(_, body, else_body) => {
Expr::When(cond, body, else_body) => {
let cond_ty = self.infer_expr(cond);
let cond_inner = self.apply_subst(cond_ty.unwrap_reactive());
let _ = self.unify(&cond_inner, &Type::Bool);
let body_ty = self.infer_expr(body);
if let Some(eb) = else_body {
let _ = self.infer_expr(eb);
let else_ty = self.infer_expr(eb);
let _ = self.unify(&body_ty, &else_ty);
}
body_ty
self.apply_subst(&body_ty)
}
Expr::Block(exprs) => {
@ -843,17 +876,41 @@ impl TypeChecker {
if arms.is_empty() {
Type::Unit
} else {
self.infer_expr(&arms[0].body)
let first_ty = self.infer_expr(&arms[0].body);
// Unify all arm types — match must return consistent type
for arm in &arms[1..] {
let arm_ty = self.infer_expr(&arm.body);
let _ = self.unify(&first_ty, &arm_ty);
}
self.apply_subst(&first_ty)
}
}
Expr::Assign(_, _, value) => {
self.infer_expr(value);
Expr::Assign(target, _op, value) => {
let val_ty = self.infer_expr(value);
// Check that assigned type is compatible with target type
if let Expr::Ident(name) = target.as_ref() {
if let Some(var_ty) = self.env.get(name).cloned() {
let var_inner = self.apply_subst(var_ty.unwrap_reactive());
let val_inner = self.apply_subst(val_ty.unwrap_reactive());
let _ = self.unify(&var_inner, &val_inner);
}
}
Type::Unit
}
Expr::ForIn { body, iter, .. } => {
let _ = self.infer_expr(iter);
Expr::ForIn { item, index, iter, body } => {
let iter_ty = self.infer_expr(iter);
let iter_inner = self.apply_subst(iter_ty.unwrap_reactive());
// Extract element type from Array<T> and bind iteration variable
let elem_ty = match &iter_inner {
Type::Array(elem) => *elem.clone(),
_ => self.fresh_tv(),
};
self.env.insert(item.clone(), elem_ty);
if let Some(idx) = index {
self.env.insert(idx.clone(), Type::Int);
}
self.infer_expr(body)
}
@ -1337,4 +1394,131 @@ mod tests {
let items_ty = checker.type_env().get("items").unwrap();
assert_eq!(items_ty.display(), "Signal<[Int]>");
}
// ── Refinement Pass 2: Branch & Structural tests ──────────────
#[test]
fn test_dot_access_through_signal() {
let mut checker = TypeChecker::new();
// Simulate: let rec = { x: 10, y: 20 }
// then access rec.x should work through Signal<Record> wrapper
let mut fields = HashMap::new();
fields.insert("x".to_string(), Type::Int);
fields.insert("y".to_string(), Type::Int);
let record_type = Type::Signal(Box::new(Type::Record(fields)));
checker.env.insert("rec".to_string(), record_type);
let expr = Expr::DotAccess(
Box::new(Expr::Ident("rec".to_string())),
"x".to_string(),
);
let ty = checker.infer_expr(&expr);
assert_eq!(ty, Type::Int);
assert!(!checker.has_errors(), "Errors: {}", checker.display_errors());
}
#[test]
fn test_if_else_branch_unification() {
let mut checker = TypeChecker::new();
let program = make_program(vec![
Declaration::Let(LetDecl {
name: "flag".to_string(),
type_annotation: None,
value: Expr::BoolLit(true),
span: span(),
}),
Declaration::Let(LetDecl {
name: "result".to_string(),
type_annotation: None,
value: Expr::If(
Box::new(Expr::Ident("flag".to_string())),
Box::new(Expr::IntLit(1)),
Box::new(Expr::IntLit(2)),
),
span: span(),
}),
]);
checker.check_program(&program);
assert!(!checker.has_errors(), "Errors: {}", checker.display_errors());
}
#[test]
fn test_for_in_binds_item_type() {
let mut checker = TypeChecker::new();
let program = make_program(vec![
Declaration::Let(LetDecl {
name: "nums".to_string(),
type_annotation: None,
value: Expr::List(vec![
Expr::IntLit(1),
Expr::IntLit(2),
Expr::IntLit(3),
]),
span: span(),
}),
Declaration::Let(LetDecl {
name: "total".to_string(),
type_annotation: None,
value: Expr::ForIn {
item: "n".to_string(),
index: None,
iter: Box::new(Expr::Ident("nums".to_string())),
body: Box::new(Expr::Ident("n".to_string())),
},
span: span(),
}),
]);
checker.check_program(&program);
assert!(!checker.has_errors(), "Errors: {}", checker.display_errors());
// The iteration variable 'n' should have been bound as Int
let n_ty = checker.type_env().get("n").unwrap();
assert_eq!(*n_ty, Type::Int);
}
#[test]
fn test_assign_type_compatibility() {
let mut checker = TypeChecker::new();
let program = make_program(vec![
Declaration::Let(LetDecl {
name: "count".to_string(),
type_annotation: None,
value: Expr::IntLit(0),
span: span(),
}),
]);
checker.check_program(&program);
assert!(!checker.has_errors());
// Assigning an Int to a Signal<Int> should be fine
let assign_expr = Expr::Assign(
Box::new(Expr::Ident("count".to_string())),
ds_parser::AssignOp::Set,
Box::new(Expr::IntLit(5)),
);
let ty = checker.infer_expr(&assign_expr);
assert_eq!(ty, Type::Unit);
assert!(!checker.has_errors(), "Errors: {}", checker.display_errors());
}
#[test]
fn test_unify_fn_arg_types() {
let mut checker = TypeChecker::new();
// Register a function: add(Int, Int) -> Int
let fn_type = Type::Fn {
params: vec![Type::Int, Type::Int],
ret: Box::new(Type::Int),
effects: vec![EffectType::Pure],
};
checker.env.insert("add".to_string(), fn_type);
// Call with correct types should work
let call_expr = Expr::Call("add".to_string(), vec![
Expr::IntLit(1),
Expr::IntLit(2),
]);
let ty = checker.infer_expr(&call_expr);
assert_eq!(ty, Type::Int);
assert!(!checker.has_errors(), "Errors: {}", checker.display_errors());
}
}