Skip to main content

lex_types/
checker.rs

1//! M3: type checker. Walks the canonical AST, infers types via unification,
2//! and checks declared signatures and effects.
3
4use crate::builtins::{module_for_import, module_scope};
5use crate::env::{TypeDefKind, TypeEnv, ty_from_canon_env};
6use crate::error::{PositionedError, TypeError};
7use crate::position::Position;
8use crate::types::*;
9use crate::unifier::{UnifyError, Unifier};
10use indexmap::IndexMap;
11use lex_ast as a;
12use std::collections::{BTreeMap, HashMap};
13
14/// Field names + type-tag schema extracted from a `Result[Record{...}, _]`
15/// return type. Used by the `parse` → `parse_strict_typed` rewrite (#322).
16type FieldSchema = (Vec<String>, Vec<(String, String)>);
17
18/// Result of checking a whole program.
19pub struct ProgramTypes {
20    pub fn_signatures: IndexMap<String, Scheme>,
21    pub type_env: TypeEnv,
22    /// For #168: per-call required-fields map for `module.parse(s)`
23    /// calls whose inferred result type is `Result[Record{...}, _]`.
24    /// Keyed by `&CExpr as *const _ as usize` so callers can do an
25    /// O(1) pointer-equality lookup during a separate AST rewrite
26    /// pass. Empty unless any matching call sites were found.
27    ///
28    /// See [`check_and_rewrite_program`] for the function that
29    /// populates this and applies the rewrite in one step.
30    pub parse_required_fields: HashMap<usize, Vec<String>>,
31    /// For #322: per-call type schema alongside the field names.
32    /// Each entry is a `Vec<(field_name, type_tag)>` parallel to
33    /// `parse_required_fields`. Used by the rewrite pass to inject
34    /// the third argument to `parse_strict`.
35    pub parse_type_schemas: HashMap<usize, Vec<(String, String)>>,
36}
37
38/// Variant of [`check_program`] that stamps a source [`Position`]
39/// onto every emitted error (#306 slice 1).
40///
41/// `positions` is keyed by function name and supplies the position
42/// of each `fn` declaration in the source. Errors from a given
43/// function are tagged with that function's position; errors that
44/// don't map to a single function (e.g. type-decl-level errors)
45/// keep `position = None`.
46///
47/// Slice 1 ships function-level granularity. Slice 1.5 will plumb
48/// per-expression spans through canonicalize so deep-body errors
49/// land on the offending sub-expression rather than its enclosing
50/// function.
51pub fn check_program_with_positions(
52    stages: &[a::Stage],
53    positions: &BTreeMap<String, Position>,
54) -> Result<ProgramTypes, Vec<PositionedError>> {
55    check_program_inner(stages, Some(positions))
56        .map_err(|errs| errs.into_iter().map(|(e, fn_name)| {
57            let pos = fn_name.as_deref().and_then(|n| positions.get(n)).cloned();
58            PositionedError::new(e, pos)
59        }).collect())
60}
61
62pub fn check_program(stages: &[a::Stage]) -> Result<ProgramTypes, Vec<TypeError>> {
63    check_program_inner(stages, None)
64        .map_err(|errs| errs.into_iter().map(|(e, _)| e).collect())
65}
66
67fn check_program_inner(
68    stages: &[a::Stage],
69    _positions: Option<&BTreeMap<String, Position>>,
70) -> Result<ProgramTypes, Vec<(TypeError, Option<String>)>> {
71    let mut tcx = Checker::new();
72    // Each entry is (error, optional fn name the error came from)
73    // so callers can resolve the error to a source position.
74    let mut errors: Vec<(TypeError, Option<String>)> = Vec::new();
75
76    // Pass 1: gather imports → bring module values into scope.
77    for stage in stages {
78        if let a::Stage::Import(i) = stage {
79            if let Some(mod_name) = module_for_import(&i.reference) {
80                if let Some(ty) = module_scope(mod_name, &tcx.type_env) {
81                    tcx.globals.insert(i.alias.clone(), Scheme {
82                        // Module-level signatures use Var(0..n) and
83                        // effect-vars on stdlib HOFs (list.map's `[E]`
84                        // etc.); generalize both.
85                        vars: collect_vars(&ty),
86                        eff_vars: collect_eff_vars(&ty),
87                        ty,
88                    });
89                    tcx.module_aliases.insert(i.alias.clone(), mod_name.to_string());
90                }
91            }
92        }
93    }
94
95    // Pass 2: register user-declared types.
96    for stage in stages {
97        if let a::Stage::TypeDecl(td) = stage {
98            if let Err(e) = tcx.type_env.add_user_type(&td.name, td.clone()) {
99                errors.push((TypeError::RecursiveTypeWithoutConstructor {
100                    at_node: "n_0".into(),
101                    name: e,
102                }, None));
103            }
104        }
105    }
106
107    // Pass 3: register fn signatures (so mutual recursion works).
108    for stage in stages {
109        if let a::Stage::FnDecl(fd) = stage {
110            let scheme = function_scheme(fd, &tcx.type_env);
111            tcx.globals.insert(fd.name.clone(), scheme);
112            // #209 slice 2: keep the original params so call-site
113            // refinement discharge can see the predicate before it
114            // gets stripped to its base type by `ty_from_canon`.
115            tcx.fn_params.insert(fd.name.clone(), fd.params.clone());
116        }
117    }
118
119    // Pass 4: check each fn body. With #306 slice 1, every emitted
120    // error is paired with the source fn it came from so the public
121    // [`check_program_with_positions`] wrapper can stamp the
122    // function's source position onto a [`PositionedError`].
123    let mut signatures = IndexMap::new();
124    for stage in stages {
125        if let a::Stage::FnDecl(fd) = stage {
126            match tcx.check_fn(fd) {
127                Ok(scheme) => { signatures.insert(fd.name.clone(), scheme); }
128                Err(es) => {
129                    errors.extend(es.into_iter().map(|e| (e, Some(fd.name.clone()))));
130                }
131            }
132        }
133    }
134
135    if errors.is_empty() {
136        // #168: walk pending parse-call records and resolve each
137        // call's return type now that all unification has settled.
138        // A call shows up here only if the call site syntactically
139        // looks like `<alias>.parse(s)` for an alias bound to one
140        // of {json, toml, yaml} via the import pass.
141        let mut parse_required_fields = HashMap::new();
142        let mut parse_type_schemas = HashMap::new();
143        for (call_ptr, ret_ty) in &tcx.pending_parse_calls {
144            if let Some((fields, schema)) = extract_record_fields_and_schema(&tcx.u, &tcx.type_env, ret_ty) {
145                parse_required_fields.insert(*call_ptr, fields);
146                parse_type_schemas.insert(*call_ptr, schema);
147            }
148        }
149        Ok(ProgramTypes {
150            fn_signatures: signatures,
151            type_env: tcx.type_env,
152            parse_required_fields,
153            parse_type_schemas,
154        })
155    } else {
156        Err(errors)
157    }
158}
159
160/// Type-check `stages` and rewrite every `module.parse(s)` call
161/// where the inferred T is a Record into the equivalent
162/// `module.parse_strict(s, [field_names])` (#168). Existing
163/// [`check_program`] keeps the old immutable signature for tests
164/// and tools that don't want the AST rewritten.
165pub fn check_and_rewrite_program(
166    stages: &mut [a::Stage],
167) -> Result<ProgramTypes, Vec<TypeError>> {
168    // Borrow as immutable for the type-check pass — the side-table
169    // it produces is keyed by `*const CExpr as usize`, and the Vec
170    // backing storage doesn't move between this borrow and the
171    // mutable one below.
172    let pt = check_program(&*stages)?;
173    if !pt.parse_required_fields.is_empty() {
174        rewrite_parse_calls(stages, &pt.parse_required_fields, &pt.parse_type_schemas);
175    }
176    Ok(pt)
177}
178
179/// Walk `stages` mutably and, for every `CExpr::Call` whose
180/// pointer (cast to `usize`) is a key in `required`, rewrite it
181/// from `module.parse(s)` into `module.parse_strict(s, [...], [...])`.
182///
183/// Assumptions:
184///
185/// - The `usize` keys come from the same physical AST passed
186///   here. This is true when called from
187///   [`check_and_rewrite_program`].
188/// - Every key corresponds to a call whose callee is
189///   `FieldAccess(_, "parse")`. The type-checker only inserts
190///   keys when this holds, so we panic if the assumption is
191///   violated — that's a checker bug, not a user error.
192fn rewrite_parse_calls(
193    stages: &mut [a::Stage],
194    required: &HashMap<usize, Vec<String>>,
195    schemas: &HashMap<usize, Vec<(String, String)>>,
196) {
197    for stage in stages.iter_mut() {
198        if let a::Stage::FnDecl(fd) = stage {
199            rewrite_in_expr(&mut fd.body, required, schemas);
200        }
201    }
202}
203
204fn rewrite_in_expr(
205    expr: &mut a::CExpr,
206    required: &HashMap<usize, Vec<String>>,
207    schemas: &HashMap<usize, Vec<(String, String)>>,
208) {
209    let ptr = expr as *const a::CExpr as usize;
210    let do_rewrite = required.get(&ptr).cloned();
211    let do_schema = schemas.get(&ptr).cloned();
212    // Recurse into children first; rewriting the call itself
213    // doesn't touch the source-arg, so the order doesn't change
214    // semantics — but processing children up front means a
215    // hypothetical nested parse-of-parse still gets rewritten
216    // correctly.
217    match expr {
218        a::CExpr::Call { callee, args } => {
219            rewrite_in_expr(callee, required, schemas);
220            for a in args.iter_mut() { rewrite_in_expr(a, required, schemas); }
221        }
222        a::CExpr::Let { value, body, .. } => {
223            rewrite_in_expr(value, required, schemas);
224            rewrite_in_expr(body, required, schemas);
225        }
226        a::CExpr::Match { scrutinee, arms } => {
227            rewrite_in_expr(scrutinee, required, schemas);
228            for arm in arms.iter_mut() { rewrite_in_expr(&mut arm.body, required, schemas); }
229        }
230        a::CExpr::Block { statements, result } => {
231            for s in statements.iter_mut() { rewrite_in_expr(s, required, schemas); }
232            rewrite_in_expr(result, required, schemas);
233        }
234        a::CExpr::Constructor { args, .. } => {
235            for a in args.iter_mut() { rewrite_in_expr(a, required, schemas); }
236        }
237        a::CExpr::RecordLit { fields } => {
238            for f in fields.iter_mut() { rewrite_in_expr(&mut f.value, required, schemas); }
239        }
240        a::CExpr::TupleLit { items } | a::CExpr::ListLit { items } => {
241            for it in items.iter_mut() { rewrite_in_expr(it, required, schemas); }
242        }
243        a::CExpr::FieldAccess { value, .. } => rewrite_in_expr(value, required, schemas),
244        a::CExpr::Lambda { body, .. } => rewrite_in_expr(body, required, schemas),
245        a::CExpr::BinOp { lhs, rhs, .. } => {
246            rewrite_in_expr(lhs, required, schemas);
247            rewrite_in_expr(rhs, required, schemas);
248        }
249        a::CExpr::UnaryOp { expr, .. } => rewrite_in_expr(expr, required, schemas),
250        a::CExpr::Return { value } => rewrite_in_expr(value, required, schemas),
251        a::CExpr::Literal { .. } | a::CExpr::Var { .. } => {}
252    }
253    if let Some(fields) = do_rewrite {
254        match expr {
255            a::CExpr::Call { callee, args } => {
256                if let a::CExpr::FieldAccess { field, .. } = callee.as_mut() {
257                    debug_assert_eq!(field, "parse",
258                        "rewrite_in_expr: only `.parse` calls should be in the table");
259                    // Use parse_strict_typed (internal, 3-arg) rather than the
260                    // public 2-arg parse_strict so direct callers aren't broken.
261                    *field = "parse_strict_typed".to_string();
262                }
263                // Second argument: List[Str] of required field names.
264                args.push(a::CExpr::ListLit {
265                    items: fields.into_iter()
266                        .map(|f| a::CExpr::Literal {
267                            value: a::CLit::Str { value: f },
268                        })
269                        .collect(),
270                });
271                // Third argument: List[(Str, Str)] type schema (#322).
272                let schema = do_schema.unwrap_or_default();
273                args.push(a::CExpr::ListLit {
274                    items: schema.into_iter()
275                        .map(|(name, tag)| a::CExpr::TupleLit {
276                            items: vec![
277                                a::CExpr::Literal { value: a::CLit::Str { value: name } },
278                                a::CExpr::Literal { value: a::CLit::Str { value: tag } },
279                            ],
280                        })
281                        .collect(),
282                });
283            }
284            _ => unreachable!("rewrite table key must point to a Call expression"),
285        }
286    }
287}
288
289/// Given an inferred return type from a `module.parse(s)` call,
290/// resolve through the unifier and any type aliases, then look
291/// for `Result[Record{...}, _]`. Returns `(field_names, schema)`
292/// where `schema` is a `Vec<(field_name, type_tag)>` for #322.
293/// Returns `None` if the shape doesn't match.
294fn extract_record_fields_and_schema(
295    u: &Unifier,
296    env: &TypeEnv,
297    ty: &Ty,
298) -> Option<FieldSchema> {
299    let resolved = u.resolve(ty);
300    let Ty::Con(ref name, ref args) = resolved else { return None; };
301    if name != "Result" || args.len() != 2 { return None; }
302    let ok_ty = u.resolve(&args[0]);
303    let unfolded = unfold_record_alias_static(env, ok_ty);
304    if let Ty::Record(fields) = unfolded {
305        let names: Vec<String> = fields.keys().cloned().collect();
306        let schema: Vec<(String, String)> = fields.iter()
307            .map(|(k, v)| (k.clone(), ty_to_tag(u, v)))
308            .collect();
309        Some((names, schema))
310    } else {
311        None
312    }
313}
314
315/// Convert a `Ty` to a compact string tag for the type schema
316/// injected by the rewrite pass (#322). The runtime uses these
317/// tags to validate JSON field values against the declared Lex type.
318fn ty_to_tag(u: &Unifier, ty: &Ty) -> String {
319    let resolved = u.resolve(ty);
320    match &resolved {
321        Ty::Prim(Prim::Int)   => "Int".to_string(),
322        Ty::Prim(Prim::Float) => "Float".to_string(),
323        Ty::Prim(Prim::Bool)  => "Bool".to_string(),
324        Ty::Prim(Prim::Str)   => "Str".to_string(),
325        Ty::Con(name, args) if name == "Option" && args.len() == 1 => {
326            format!("Option[{}]", ty_to_tag(u, &args[0]))
327        }
328        Ty::List(inner) => {
329            format!("List[{}]", ty_to_tag(u, inner))
330        }
331        Ty::Record(_) => "Record".to_string(),
332        _ => "Any".to_string(),
333    }
334}
335
336/// Standalone version of `Checker::unfold_record_alias` —
337/// resolves a `Ty::Con` whose definition is a type alias (record
338/// or otherwise) to the underlying type. Module-level helper
339/// because we need it after the `Checker` has been
340/// moved/destructured.
341fn unfold_record_alias_static(env: &TypeEnv, ty: Ty) -> Ty {
342    if let Ty::Con(ref n, ref args) = ty {
343        if args.is_empty() {
344            if let Some(td) = env.types.get(n) {
345                if let TypeDefKind::Alias(inner) = &td.kind {
346                    return inner.clone();
347                }
348            }
349        }
350    }
351    ty
352}
353
354fn collect_vars(t: &Ty) -> Vec<TyVarId> {
355    let mut out = Vec::new();
356    fn walk(t: &Ty, out: &mut Vec<TyVarId>) {
357        match t {
358            Ty::Var(v) => { if !out.contains(v) { out.push(*v); } }
359            Ty::Prim(_) | Ty::Unit | Ty::Never => {}
360            Ty::List(inner) => walk(inner, out),
361            Ty::Tuple(items) => for it in items { walk(it, out); },
362            Ty::Record(fs) => for v in fs.values() { walk(v, out); },
363            Ty::Con(_, args) => for a in args { walk(a, out); },
364            Ty::Function { params, ret, .. } => {
365                for p in params { walk(p, out); }
366                walk(ret, out);
367            }
368        }
369    }
370    walk(t, &mut out);
371    out
372}
373
374/// Walk a type and collect every effect-row variable id that appears
375/// inside any function-type's effect set. Used to generalize stdlib
376/// HOF schemes alongside ordinary type vars.
377fn collect_eff_vars(t: &Ty) -> Vec<u32> {
378    let mut out = Vec::new();
379    fn walk(t: &Ty, out: &mut Vec<u32>) {
380        match t {
381            Ty::Var(_) | Ty::Prim(_) | Ty::Unit | Ty::Never => {}
382            Ty::List(inner) => walk(inner, out),
383            Ty::Tuple(items) => for it in items { walk(it, out); },
384            Ty::Record(fs) => for v in fs.values() { walk(v, out); },
385            Ty::Con(_, args) => for a in args { walk(a, out); },
386            Ty::Function { params, effects, ret } => {
387                if let Some(v) = effects.var {
388                    if !out.contains(&v) { out.push(v); }
389                }
390                for p in params { walk(p, out); }
391                walk(ret, out);
392            }
393        }
394    }
395    walk(t, &mut out);
396    out
397}
398
399fn function_scheme(fd: &a::FnDecl, env: &TypeEnv) -> Scheme {
400    // Collect type-param ids in order; map their names to fresh Var(idx).
401    let params: Vec<Ty> = fd.params.iter().map(|p| ty_from_canon_env(&p.ty, &fd.type_params, env)).collect();
402    let ret = ty_from_canon_env(&fd.return_type, &fd.type_params, env);
403    // Plumb effect args (#207). A canonical-AST `EffectDecl` already
404    // carries `Option<EffectArg>`; map it into the type-system kind so
405    // subsumption can honor parameterized effects.
406    let effects = EffectSet {
407        concrete: {
408            let mut s = std::collections::BTreeSet::new();
409            for e in &fd.effects {
410                let arg = e.arg.as_ref().map(|a| match a {
411                    a::EffectArg::Str { value } => crate::types::EffectArg::Str(value.clone()),
412                    a::EffectArg::Int { value } => crate::types::EffectArg::Int(*value),
413                    a::EffectArg::Ident { value } => crate::types::EffectArg::Ident(value.clone()),
414                });
415                s.insert(crate::types::EffectKind { name: e.name.clone(), arg });
416            }
417            s
418        },
419        var: None,
420    };
421    let ty = Ty::Function { params, effects, ret: Box::new(ret) };
422    let vars: Vec<TyVarId> = (0..fd.type_params.len() as u32).collect();
423    // User-declared functions don't carry effect-row variables today
424    // (the surface syntax has no `[E]` form for user types). Only
425    // stdlib HOFs do, and those are loaded via module_scope.
426    Scheme { vars, eff_vars: Vec::new(), ty }
427}
428
429struct Checker {
430    u: Unifier,
431    type_env: TypeEnv,
432    globals: IndexMap<String, Scheme>,
433    /// Imported alias → canonical module name (e.g. `cfg` → `toml`).
434    /// Populated during the import pass; consulted by `check_call`
435    /// to recognise `cfg.parse(...)` as a stdlib parse call.
436    module_aliases: IndexMap<String, String>,
437    /// For #168: every `<alias>.parse(s)` call where alias is in
438    /// `module_aliases` and maps to {json, toml, yaml}, recorded
439    /// here as `(call_pointer_as_usize, return_type_var)`. After
440    /// the whole program type-checks, we walk this and resolve
441    /// each return type through the unifier — at that point any
442    /// `Result[Manifest, _]` constraints from match patterns or
443    /// let-annotations have settled.
444    pending_parse_calls: Vec<(usize, Ty)>,
445    /// Per-function param list, retained so call-site discharge can
446    /// see refinement predicates (#209 slice 2). The main `globals`
447    /// scheme strips refinements (`Refined` unifies as its base);
448    /// this side-table keeps the pre-stripped `TypeExpr` available
449    /// for static discharge of literal arguments.
450    fn_params: IndexMap<String, Vec<a::Param>>,
451}
452
453impl Checker {
454    fn new() -> Self {
455        Self {
456            u: Unifier::new(),
457            type_env: TypeEnv::new_with_builtins(),
458            globals: IndexMap::new(),
459            module_aliases: IndexMap::new(),
460            pending_parse_calls: Vec::new(),
461            fn_params: IndexMap::new(),
462        }
463    }
464
465    /// If `ty` is a `Ty::Con(name, [])` whose definition is a type
466    /// alias (record or otherwise), return the aliased type.
467    /// Otherwise return `ty` unchanged.
468    fn unfold_record_alias(&self, ty: Ty) -> Ty {
469        if let Ty::Con(ref n, ref args) = ty {
470            if args.is_empty() {
471                if let Some(td) = self.type_env.types.get(n) {
472                    if let TypeDefKind::Alias(inner) = &td.kind {
473                        return inner.clone();
474                    }
475                }
476            }
477        }
478        ty
479    }
480
481    /// True iff `ty` is a 0-arg `Ty::Con(name, [])` whose definition
482    /// is a `TypeDefKind::Alias`. Used by `unify_coerce_inner` to
483    /// detect the case where both sides are nominal aliases and
484    /// unfolding would collapse the nominal distinction (#323).
485    fn is_alias_con(&self, ty: &Ty) -> bool {
486        if let Ty::Con(name, args) = ty {
487            if args.is_empty() {
488                if let Some(td) = self.type_env.types.get(name) {
489                    return matches!(td.kind, TypeDefKind::Alias(_));
490                }
491            }
492        }
493        false
494    }
495
496    /// Whether `callee` is a `<alias>.parse` field access where
497    /// `<alias>` was imported from one of the stdlib modules whose
498    /// `parse` returns `Result[T, Str]` and whose `parse_strict`
499    /// shape exists for #168 enforcement (json / toml / yaml).
500    fn is_module_parse_call(&self, callee: &a::CExpr) -> bool {
501        if let a::CExpr::FieldAccess { value, field } = callee {
502            if field != "parse" { return false; }
503            if let a::CExpr::Var { name } = value.as_ref() {
504                if let Some(module) = self.module_aliases.get(name) {
505                    return matches!(module.as_str(), "json" | "toml" | "yaml");
506                }
507            }
508        }
509        false
510    }
511
512    /// Unify two types, asymmetrically coercing an anonymous record
513    /// against a nominal record alias at any level of nesting. So a
514    /// `{ x: 1, y: 2 }` literal can be passed to a fn taking
515    /// `Inner = { x :: Int, y :: Int }`, even when the literal is the
516    /// inner field of an outer record literal.
517    ///
518    /// We deliberately keep nominal-vs-nominal mismatches strict: two
519    /// distinct `Ty::Con` names won't unify just because their record
520    /// shapes match. The coercion fires only when one side is a bare
521    /// `Ty::Record` and the other is a `Ty::Con` whose alias is a
522    /// record.
523    fn unify_with_record_coercion(&mut self, a: &Ty, b: &Ty) -> Result<(), UnifyError> {
524        let a = self.u.resolve(a);
525        let b = self.u.resolve(b);
526        self.unify_coerce_inner(a, b)
527    }
528
529    fn unify_coerce_inner(&mut self, a: Ty, b: Ty) -> Result<(), UnifyError> {
530        // #323: alias unfolding. If exactly one side is an `alias-Con`
531        // — a 0-arg `Ty::Con(name, [])` whose definition is a type
532        // alias (Record or non-record) — unfold both sides so the
533        // structural cases below can match (`Errors` ↔ `List[…]`,
534        // `Path` ↔ `Tuple(…)`, `Maybe` ↔ `Option[…]`,
535        // `UserId` ↔ `Int`, …).
536        //
537        // Three cases intentionally bypass unfolding:
538        //
539        // - **Same-named Cons** (`Test` vs `Test`): preserve nominal
540        //   identity. The Con-Con same-name case below recurses on
541        //   args; eager unfold here would force the nominal name
542        //   to evaporate, breaking unifications elsewhere that
543        //   still see the nominal `Con`.
544        // - **Var on either side**: don't unfold against an unbound
545        //   variable, because the plain unifier would bind the var
546        //   to the unfolded shape and lose the nominal name. The
547        //   var binds to the nominal `Con` instead, and later
548        //   unifications against concrete shapes re-enter this
549        //   function and unfold then.
550        // - **Two distinct alias-Cons** (`Apple` vs `Box`, both
551        //   declared as record aliases with identical shapes):
552        //   preserve nominal distinction between aliases. Unfolding
553        //   both would collapse the test of "same shape, different
554        //   names" into "same shape" and erase the names.
555        let (a, b) = match (&a, &b) {
556            (Ty::Con(n1, _), Ty::Con(n2, _)) if n1 == n2 => (a, b),
557            (Ty::Var(_), _) | (_, Ty::Var(_)) => (a, b),
558            (Ty::Con(_, _), Ty::Con(_, _))
559                if self.is_alias_con(&a) && self.is_alias_con(&b) =>
560            {
561                (a, b)
562            }
563            _ => {
564                let a_u = if let Ty::Con(_, _) = &a {
565                    self.unfold_record_alias(a.clone())
566                } else {
567                    a
568                };
569                let b_u = if let Ty::Con(_, _) = &b {
570                    self.unfold_record_alias(b.clone())
571                } else {
572                    b
573                };
574                (a_u, b_u)
575            }
576        };
577
578        match (&a, &b) {
579            (Ty::Record(fa), Ty::Record(fb)) => {
580                if fa.len() != fb.len() {
581                    return Err(UnifyError::Mismatch { a: a.clone(), b: b.clone() });
582                }
583                for (k, va) in fa.clone() {
584                    match fb.get(&k) {
585                        Some(vb) => self.unify_coerce_inner(va, vb.clone())?,
586                        None => return Err(UnifyError::Mismatch { a: a.clone(), b: b.clone() }),
587                    }
588                }
589                Ok(())
590            }
591            (Ty::List(ta), Ty::List(tb)) => {
592                self.unify_coerce_inner((**ta).clone(), (**tb).clone())
593            }
594            (Ty::Tuple(xs), Ty::Tuple(ys)) if xs.len() == ys.len() => {
595                for (x, y) in xs.clone().into_iter().zip(ys.clone()) {
596                    self.unify_coerce_inner(x, y)?;
597                }
598                Ok(())
599            }
600            // Recurse into Con-Con pairs so record-alias coercion reaches
601            // arbitrary nesting depth (e.g. Result[T, MyAlias]) (#328).
602            (Ty::Con(n1, a1), Ty::Con(n2, a2)) if n1 == n2 && a1.len() == a2.len() => {
603                for (x, y) in a1.clone().into_iter().zip(a2.clone()) {
604                    self.unify_coerce_inner(x, y)?;
605                }
606                Ok(())
607            }
608            // #345: recurse into Function types so alias coercion fires on
609            // closure params / return types. Without this, a closure annotated
610            // `(Errors, Errors) -> Errors` fails to unify with the expected
611            // `(List[?n], ?m) -> List[?n]` even though `Errors = List[Error]`.
612            (Ty::Function { params: pa, effects: ea, ret: ra },
613             Ty::Function { params: pb, effects: eb, ret: rb })
614            if pa.len() == pb.len() => {
615                for (x, y) in pa.clone().into_iter().zip(pb.clone()) {
616                    self.unify_coerce_inner(x, y)?;
617                }
618                self.u.unify_effects(ea, eb).map_err(|_| UnifyError::Mismatch { a: a.clone(), b: b.clone() })?;
619                self.unify_coerce_inner((**ra).clone(), (**rb).clone())
620            }
621            _ => self.u.unify(&a, &b),
622        }
623    }
624
625    fn check_fn(&mut self, fd: &a::FnDecl) -> Result<Scheme, Vec<TypeError>> {
626        // Instantiate fn's signature with fresh vars for its type params.
627        let scheme = function_scheme(fd, &self.type_env);
628        let (param_tys, declared_effects, ret_ty) = match instantiate(&scheme, &mut self.u) {
629            Ty::Function { params, effects, ret } => (params, effects, *ret),
630            _ => unreachable!(),
631        };
632
633        let mut locals: IndexMap<String, Ty> = IndexMap::new();
634        for (p, t) in fd.params.iter().zip(param_tys.iter()) {
635            locals.insert(p.name.clone(), t.clone());
636        }
637
638        let mut inferred_effects = EffectSet::empty();
639        let body_ty = self.check_expr(&fd.body, "n_0", &mut locals, &mut inferred_effects)
640            .map_err(|e| vec![e])?;
641
642        // The body may produce an anonymous record literal where the
643        // signature expects a nominal record alias (and vice-versa,
644        // and at any nested level). `unify_with_record_coercion`
645        // handles that asymmetry while keeping nominal-vs-nominal
646        // mismatches strict.
647        if let Err(e) = self.unify_with_record_coercion(&body_ty, &ret_ty) {
648            return Err(vec![mismatch_err("n_0", e, &self.u, vec![format!("in function `{}`", fd.name)])]);
649        }
650
651        if !inferred_effects.is_subset(&declared_effects) {
652            // Pick the first undeclared effect for the error.
653            for e in inferred_effects.concrete.iter() {
654                if !declared_effects.concrete.iter().any(|d| d.subsumes(e)) {
655                    return Err(vec![TypeError::EffectNotDeclared {
656                        at_node: "n_0".into(),
657                        effect: e.pretty(),
658                    }]);
659                }
660            }
661        }
662
663        // #369: signature-level examples. Pure-only in v1; arg arity
664        // must match params; each arg type-checks against its param,
665        // each expected type-checks against the return type. Behavioral
666        // equivalence (run the body, compare to expected) is a follow-up
667        // — this slice catches type-level drift, which is already a
668        // common source of stale examples.
669        if !fd.examples.is_empty() {
670            if !declared_effects.concrete.is_empty() {
671                return Err(vec![TypeError::ExamplesOnEffectfulFn {
672                    at_node: "n_0".into(),
673                    fn_name: fd.name.clone(),
674                }]);
675            }
676            for (case_index, ex) in fd.examples.iter().enumerate() {
677                if ex.args.len() != param_tys.len() {
678                    return Err(vec![TypeError::ExampleArityMismatch {
679                        at_node: "n_0".into(),
680                        fn_name: fd.name.clone(),
681                        case_index,
682                        expected: param_tys.len(),
683                        got: ex.args.len(),
684                    }]);
685                }
686                let mut example_locals: IndexMap<String, Ty> = IndexMap::new();
687                let mut example_effects = EffectSet::empty();
688                for (i, (arg, expected_ty)) in
689                    ex.args.iter().zip(param_tys.iter()).enumerate()
690                {
691                    let arg_ty = self
692                        .check_expr(arg, "n_0", &mut example_locals, &mut example_effects)
693                        .map_err(|e| vec![e])?;
694                    if let Err(e) = self.unify_with_record_coercion(&arg_ty, expected_ty) {
695                        return Err(vec![mismatch_err(
696                            "n_0",
697                            e,
698                            &self.u,
699                            vec![format!(
700                                "in example #{} for `{}`, argument {}",
701                                case_index + 1,
702                                fd.name,
703                                i + 1
704                            )],
705                        )]);
706                    }
707                }
708                let expected_ty = self
709                    .check_expr(&ex.expected, "n_0", &mut example_locals, &mut example_effects)
710                    .map_err(|e| vec![e])?;
711                if let Err(e) = self.unify_with_record_coercion(&expected_ty, &ret_ty) {
712                    return Err(vec![mismatch_err(
713                        "n_0",
714                        e,
715                        &self.u,
716                        vec![format!(
717                            "in example #{} for `{}`, expected value",
718                            case_index + 1,
719                            fd.name
720                        )],
721                    )]);
722                }
723                // The example's args/expected are expected to be pure
724                // by construction (literals in the common case); if
725                // they invoked effects, they'd break the pure-only
726                // discipline. Reject the first one via the same effect rule.
727                if let Some(e) = example_effects.concrete.iter().next() {
728                    return Err(vec![TypeError::EffectNotDeclared {
729                        at_node: "n_0".into(),
730                        effect: e.pretty(),
731                    }]);
732                }
733            }
734        }
735
736        Ok(scheme)
737    }
738
739    fn check_expr(
740        &mut self,
741        e: &a::CExpr,
742        node_id: &str,
743        locals: &mut IndexMap<String, Ty>,
744        effs: &mut EffectSet,
745    ) -> Result<Ty, TypeError> {
746        match e {
747            a::CExpr::Literal { value } => Ok(lit_type(value)),
748            a::CExpr::Var { name } => {
749                if let Some(t) = locals.get(name) {
750                    return Ok(t.clone());
751                }
752                if let Some(scheme) = self.globals.get(name).cloned() {
753                    return Ok(instantiate(&scheme, &mut self.u));
754                }
755                Err(TypeError::UnknownIdentifier { at_node: node_id.into(), name: name.clone() })
756            }
757            a::CExpr::Constructor { name, args } => self.check_constructor(name, args, node_id, locals, effs),
758            a::CExpr::Call { callee, args } => self.check_call(e, callee, args, node_id, locals, effs),
759            a::CExpr::Let { name, ty, value, body } => {
760                let v_ty = self.check_expr(value, node_id, locals, effs)?;
761                if let Some(declared) = ty {
762                    let d = ty_from_canon_env(declared, &[], &self.type_env);
763                    if let Err(err) = self.unify_with_record_coercion(&v_ty, &d) {
764                        return Err(mismatch_err(node_id, err, &self.u, vec![format!("in let `{}`", name)]));
765                    }
766                }
767                let prev = locals.insert(name.clone(), v_ty);
768                let body_ty = self.check_expr(body, node_id, locals, effs)?;
769                match prev {
770                    Some(p) => { locals.insert(name.clone(), p); }
771                    None => { locals.shift_remove(name); }
772                }
773                Ok(body_ty)
774            }
775            a::CExpr::Match { scrutinee, arms } => {
776                let scrut_ty = self.check_expr(scrutinee, node_id, locals, effs)?;
777                if arms.is_empty() {
778                    return Err(TypeError::NonExhaustiveMatch {
779                        at_node: node_id.into(), missing: vec!["_".into()]
780                    });
781                }
782                let result_ty = self.u.fresh();
783                for arm in arms {
784                    let mut arm_locals = locals.clone();
785                    self.bind_pattern(&arm.pattern, &scrut_ty, &mut arm_locals, node_id)?;
786                    let arm_ty = self.check_expr(&arm.body, node_id, &mut arm_locals, effs)?;
787                    if let Err(err) = self.unify_with_record_coercion(&arm_ty, &result_ty) {
788                        return Err(mismatch_err(node_id, err, &self.u, vec!["in match arm".into()]));
789                    }
790                }
791                Ok(result_ty)
792            }
793            a::CExpr::Block { statements, result } => {
794                for s in statements {
795                    self.check_expr(s, node_id, locals, effs)?;
796                }
797                self.check_expr(result, node_id, locals, effs)
798            }
799            a::CExpr::RecordLit { fields } => {
800                let mut tys = IndexMap::new();
801                for f in fields {
802                    if tys.contains_key(&f.name) {
803                        return Err(TypeError::DuplicateField {
804                            at_node: node_id.into(), field: f.name.clone()
805                        });
806                    }
807                    let ft = self.check_expr(&f.value, node_id, locals, effs)?;
808                    tys.insert(f.name.clone(), ft);
809                }
810                Ok(Ty::Record(tys))
811            }
812            a::CExpr::TupleLit { items } => {
813                let mut ts = Vec::new();
814                for it in items { ts.push(self.check_expr(it, node_id, locals, effs)?); }
815                Ok(Ty::Tuple(ts))
816            }
817            a::CExpr::ListLit { items } => {
818                let elem = self.u.fresh();
819                for it in items {
820                    let t = self.check_expr(it, node_id, locals, effs)?;
821                    if let Err(err) = self.unify_with_record_coercion(&t, &elem) {
822                        return Err(mismatch_err(node_id, err, &self.u, vec!["in list literal".into()]));
823                    }
824                }
825                Ok(Ty::List(Box::new(elem)))
826            }
827            a::CExpr::FieldAccess { value, field } => {
828                let vt = self.check_expr(value, node_id, locals, effs)?;
829                let resolved = self.u.resolve(&vt);
830                // Unfold a Record-aliased Con (e.g. `type Request = { ... }`).
831                let resolved = match resolved {
832                    Ty::Con(ref n, _) => match self.type_env.types.get(n) {
833                        Some(td) => match &td.kind {
834                            TypeDefKind::Alias(inner @ Ty::Record(_)) => inner.clone(),
835                            _ => resolved,
836                        },
837                        None => resolved,
838                    },
839                    other => other,
840                };
841                match resolved {
842                    Ty::Record(fields) => fields.get(field).cloned()
843                        .ok_or_else(|| TypeError::UnknownField {
844                            at_node: node_id.into(),
845                            record_type: Ty::Record(fields.clone()).pretty(),
846                            field: field.clone(),
847                        }),
848                    other => Err(TypeError::TypeMismatch {
849                        at_node: node_id.into(),
850                        expected: "record".into(),
851                        got: other.pretty(),
852                        context: vec![format!("field access `.{}`", field)],
853                    }),
854                }
855            }
856            a::CExpr::Lambda { params, return_type, effects: l_effects, body } => {
857                let param_tys: Vec<Ty> = params.iter().map(|p| ty_from_canon_env(&p.ty, &[], &self.type_env)).collect();
858                let ret_ty = ty_from_canon_env(return_type, &[], &self.type_env);
859                let declared = EffectSet {
860                    concrete: {
861                        let mut s = std::collections::BTreeSet::new();
862                        for e in l_effects {
863                            let arg = e.arg.as_ref().map(|a| match a {
864                                a::EffectArg::Str { value } => crate::types::EffectArg::Str(value.clone()),
865                                a::EffectArg::Int { value } => crate::types::EffectArg::Int(*value),
866                                a::EffectArg::Ident { value } => crate::types::EffectArg::Ident(value.clone()),
867                            });
868                            s.insert(crate::types::EffectKind { name: e.name.clone(), arg });
869                        }
870                        s
871                    },
872                    var: None,
873                };
874                let mut inner_locals = locals.clone();
875                for (p, t) in params.iter().zip(param_tys.iter()) {
876                    inner_locals.insert(p.name.clone(), t.clone());
877                }
878                let mut inner_effs = EffectSet::empty();
879                let body_ty = self.check_expr(body, node_id, &mut inner_locals, &mut inner_effs)?;
880                if let Err(err) = self.unify_with_record_coercion(&body_ty, &ret_ty) {
881                    return Err(mismatch_err(node_id, err, &self.u, vec!["in lambda body".into()]));
882                }
883                if !inner_effs.is_subset(&declared) {
884                    for e in inner_effs.concrete.iter() {
885                        if !declared.concrete.iter().any(|d| d.subsumes(e)) {
886                            return Err(TypeError::EffectNotDeclared {
887                                at_node: node_id.into(),
888                                effect: e.pretty(),
889                            });
890                        }
891                    }
892                }
893                Ok(Ty::function(param_tys, declared, ret_ty))
894            }
895            a::CExpr::BinOp { op, lhs, rhs } => self.check_binop(op, lhs, rhs, node_id, locals, effs),
896            a::CExpr::UnaryOp { op, expr } => {
897                let t = self.check_expr(expr, node_id, locals, effs)?;
898                match op.as_str() {
899                    "-" => {
900                        // Either Int or Float; we pick Int by default if unconstrained.
901                        let r = self.u.resolve(&t);
902                        match r {
903                            Ty::Prim(Prim::Int) | Ty::Prim(Prim::Float) => Ok(t),
904                            Ty::Var(_) => {
905                                // default to Int.
906                                self.u.unify(&t, &Ty::int()).map_err(|e| mismatch_err(node_id, e, &self.u, vec![]))?;
907                                Ok(Ty::int())
908                            }
909                            other => Err(TypeError::TypeMismatch {
910                                at_node: node_id.into(),
911                                expected: "Int or Float".into(),
912                                got: other.pretty(),
913                                context: vec!["unary `-`".into()],
914                            }),
915                        }
916                    }
917                    "not" => {
918                        self.u.unify(&t, &Ty::bool()).map_err(|e| mismatch_err(node_id, e, &self.u, vec!["unary `not`".into()]))?;
919                        Ok(Ty::bool())
920                    }
921                    other => panic!("unknown unary op: {other}"),
922                }
923            }
924            a::CExpr::Return { value } => {
925                // For now treat Return as having type Never; the surrounding
926                // context will unify with the actual return type.
927                self.check_expr(value, node_id, locals, effs)?;
928                Ok(Ty::Never)
929            }
930        }
931    }
932
933    fn check_binop(
934        &mut self,
935        op: &str,
936        lhs: &a::CExpr,
937        rhs: &a::CExpr,
938        node_id: &str,
939        locals: &mut IndexMap<String, Ty>,
940        effs: &mut EffectSet,
941    ) -> Result<Ty, TypeError> {
942        let lt = self.check_expr(lhs, node_id, locals, effs)?;
943        let rt = self.check_expr(rhs, node_id, locals, effs)?;
944        match op {
945            "+" => {
946                // #308: `+` is overloaded over Int, Float, and Str.
947                // Str concatenation dispatches at the VM layer
948                // (Op::NumAdd in bytecode handles all three).
949                // #323: unfold one-step type aliases on the resolved
950                // type so `type UserId = Int; id + id` works under
951                // Option-A transparency. Same below for the other
952                // numeric operator groups.
953                self.u.unify(&lt, &rt).map_err(|e| mismatch_err(node_id, e, &self.u, vec![format!("operator `{op}`")]))?;
954                let r = self.unfold_record_alias(self.u.resolve(&lt));
955                match r {
956                    Ty::Prim(Prim::Int) | Ty::Prim(Prim::Float) | Ty::Prim(Prim::Str) => Ok(lt),
957                    Ty::Var(_) => {
958                        self.u.unify(&lt, &Ty::int()).map_err(|e| mismatch_err(node_id, e, &self.u, vec![format!("operator `{op}`")]))?;
959                        Ok(Ty::int())
960                    }
961                    other => Err(TypeError::TypeMismatch {
962                        at_node: node_id.into(),
963                        expected: "Int, Float, or Str".into(),
964                        got: other.pretty(),
965                        context: vec![format!("operator `{op}`")],
966                    }),
967                }
968            }
969            "-" | "*" | "/" | "%" => {
970                self.u.unify(&lt, &rt).map_err(|e| mismatch_err(node_id, e, &self.u, vec![format!("operator `{op}`")]))?;
971                let r = self.unfold_record_alias(self.u.resolve(&lt));
972                match r {
973                    Ty::Prim(Prim::Int) | Ty::Prim(Prim::Float) => Ok(lt),
974                    Ty::Var(_) => {
975                        self.u.unify(&lt, &Ty::int()).map_err(|e| mismatch_err(node_id, e, &self.u, vec![format!("operator `{op}`")]))?;
976                        Ok(Ty::int())
977                    }
978                    other => Err(TypeError::TypeMismatch {
979                        at_node: node_id.into(),
980                        expected: "Int or Float".into(),
981                        got: other.pretty(),
982                        context: vec![format!("operator `{op}`")],
983                    }),
984                }
985            }
986            "==" | "!=" => {
987                self.u.unify(&lt, &rt).map_err(|e| mismatch_err(node_id, e, &self.u, vec![format!("operator `{op}`")]))?;
988                Ok(Ty::bool())
989            }
990            "<" | "<=" | ">" | ">=" => {
991                self.u.unify(&lt, &rt).map_err(|e| mismatch_err(node_id, e, &self.u, vec![format!("operator `{op}`")]))?;
992                let r = self.unfold_record_alias(self.u.resolve(&lt));
993                match r {
994                    Ty::Prim(Prim::Int) | Ty::Prim(Prim::Float) | Ty::Prim(Prim::Str) => Ok(Ty::bool()),
995                    Ty::Var(_) => {
996                        self.u.unify(&lt, &Ty::int()).map_err(|e| mismatch_err(node_id, e, &self.u, vec![format!("operator `{op}`")]))?;
997                        Ok(Ty::bool())
998                    }
999                    other => Err(TypeError::TypeMismatch {
1000                        at_node: node_id.into(),
1001                        expected: "Int, Float, or Str".into(),
1002                        got: other.pretty(),
1003                        context: vec![format!("operator `{op}`")],
1004                    }),
1005                }
1006            }
1007            "and" | "or" => {
1008                self.u.unify(&lt, &Ty::bool()).map_err(|e| mismatch_err(node_id, e, &self.u, vec![format!("operator `{op}`")]))?;
1009                self.u.unify(&rt, &Ty::bool()).map_err(|e| mismatch_err(node_id, e, &self.u, vec![format!("operator `{op}`")]))?;
1010                Ok(Ty::bool())
1011            }
1012            other => panic!("unknown binop: {other}"),
1013        }
1014    }
1015
1016    fn check_call(
1017        &mut self,
1018        call_expr: &a::CExpr,
1019        callee: &a::CExpr,
1020        args: &[a::CExpr],
1021        node_id: &str,
1022        locals: &mut IndexMap<String, Ty>,
1023        effs: &mut EffectSet,
1024    ) -> Result<Ty, TypeError> {
1025        // #168: snapshot the call's address before the recursive
1026        // descent so we can later rewrite this exact node. Pointer
1027        // identity is only meaningful while the AST stays put,
1028        // which it does until check_program returns and the AST
1029        // is handed back to the caller. `is_module_parse_call`
1030        // recognises `<alias>.parse` where alias was bound to one
1031        // of {json, toml, yaml} during the import pass.
1032        let parse_call_ptr = if self.is_module_parse_call(callee) {
1033            Some(call_expr as *const a::CExpr as usize)
1034        } else {
1035            None
1036        };
1037        let callee_ty = self.check_expr(callee, node_id, locals, effs)?;
1038        let resolved = self.u.resolve(&callee_ty);
1039        match resolved {
1040            Ty::Function { params, effects, ret } => {
1041                if params.len() != args.len() {
1042                    return Err(TypeError::ArityMismatch {
1043                        at_node: node_id.into(),
1044                        expected: params.len(),
1045                        got: args.len(),
1046                    });
1047                }
1048                for (i, (a, p)) in args.iter().zip(params.iter()).enumerate() {
1049                    let at = self.check_expr(a, node_id, locals, effs)?;
1050                    if let Err(err) = self.unify_with_record_coercion(&at, p) {
1051                        return Err(mismatch_err(node_id, err, &self.u, vec![format!("argument {} of call", i + 1)]));
1052                    }
1053                }
1054                // #209 slice 2: refinement discharge for direct named
1055                // calls. Look up the callee's original params (kept
1056                // pre-strip in `fn_params`), and for each refined
1057                // param attempt static discharge against the call
1058                // arg. Refuted = type error; Deferred = pass (slice
1059                // 3 will add a runtime residual check).
1060                if let a::CExpr::Var { name: callee_name } = callee {
1061                    if let Some(callee_params) = self.fn_params.get(callee_name).cloned() {
1062                        for (i, (param, arg)) in callee_params.iter().zip(args.iter()).enumerate() {
1063                            if let a::TypeExpr::Refined { binding, predicate, .. } = &param.ty {
1064                                let outcome = crate::discharge::try_discharge(
1065                                    predicate, binding, arg);
1066                                if let crate::discharge::DischargeOutcome::Refuted { reason } = outcome {
1067                                    return Err(TypeError::RefinementViolation {
1068                                        at_node: node_id.into(),
1069                                        fn_name: callee_name.clone(),
1070                                        param_index: i,
1071                                        binding: binding.clone(),
1072                                        reason,
1073                                    });
1074                                }
1075                            }
1076                        }
1077                    }
1078                }
1079                // Re-resolve effects after unifying args: an effect-row
1080                // variable on the function type may have been bound by
1081                // an argument's closure type, and we want the
1082                // *post-binding* set when propagating to the caller.
1083                let resolved_effects = self.u.resolve_effects(&effects);
1084                effs.extend(&resolved_effects);
1085                // #168: snapshot the post-arg-unification return type
1086                // for stdlib parse calls. Resolution to the eventual
1087                // `Result[Record{...}, _]` shape happens at the end
1088                // of `check_program` once the whole program's
1089                // unification has settled — match-pattern annotations
1090                // and let-type-annotations may bind T after this
1091                // point.
1092                if let Some(ptr) = parse_call_ptr {
1093                    self.pending_parse_calls.push((ptr, (*ret).clone()));
1094                }
1095                Ok(*ret)
1096            }
1097            Ty::Var(_) => {
1098                // Build a function type and unify.
1099                let mut p_tys = Vec::new();
1100                for a in args { p_tys.push(self.check_expr(a, node_id, locals, effs)?); }
1101                let r = self.u.fresh();
1102                let f = Ty::function(p_tys, EffectSet::empty(), r.clone());
1103                self.u.unify(&callee_ty, &f).map_err(|e| mismatch_err(node_id, e, &self.u, vec!["in call".into()]))?;
1104                Ok(r)
1105            }
1106            other => Err(TypeError::TypeMismatch {
1107                at_node: node_id.into(),
1108                expected: "function".into(),
1109                got: other.pretty(),
1110                context: vec!["in call".into()],
1111            }),
1112        }
1113    }
1114
1115    fn check_constructor(
1116        &mut self,
1117        name: &str,
1118        args: &[a::CExpr],
1119        node_id: &str,
1120        locals: &mut IndexMap<String, Ty>,
1121        effs: &mut EffectSet,
1122    ) -> Result<Ty, TypeError> {
1123        let owning = self.type_env.ctor_to_type.get(name).cloned()
1124            .ok_or_else(|| TypeError::UnknownVariant {
1125                at_node: node_id.into(),
1126                constructor: name.to_string(),
1127            })?;
1128        let def = self.type_env.types.get(&owning).cloned()
1129            .expect("ctor_to_type points to a real type");
1130        let variants = match &def.kind {
1131            TypeDefKind::Union(v) => v.clone(),
1132            _ => return Err(TypeError::UnknownVariant {
1133                at_node: node_id.into(),
1134                constructor: name.to_string(),
1135            }),
1136        };
1137        // Instantiate the type's params with fresh vars; substitute into
1138        // both the variant's payload type and the resulting Con(...).
1139        let mut subst = IndexMap::new();
1140        let mut con_args = Vec::with_capacity(def.params.len());
1141        for (i, _p) in def.params.iter().enumerate() {
1142            let fresh = self.u.fresh();
1143            subst.insert(i as u32, fresh.clone());
1144            con_args.push(fresh);
1145        }
1146        let payload = variants.get(name).cloned().flatten();
1147        match (payload, args) {
1148            (None, []) => Ok(Ty::Con(owning, con_args)),
1149            (Some(payload), args) => {
1150                let inst_payload = subst_vars(&payload, &subst, &IndexMap::new());
1151                let arg_count = match &inst_payload {
1152                    Ty::Tuple(items) => items.len(),
1153                    _ => 1,
1154                };
1155                if arg_count != args.len() {
1156                    return Err(TypeError::ArityMismatch {
1157                        at_node: node_id.into(),
1158                        expected: arg_count,
1159                        got: args.len(),
1160                    });
1161                }
1162                if args.len() == 1 {
1163                    let at = self.check_expr(&args[0], node_id, locals, effs)?;
1164                    self.unify_with_record_coercion(&at, &inst_payload).map_err(|e| mismatch_err(node_id, e, &self.u, vec![format!("constructor `{}`", name)]))?;
1165                } else if let Ty::Tuple(items) = inst_payload {
1166                    for (i, (a, t)) in args.iter().zip(items.iter()).enumerate() {
1167                        let at = self.check_expr(a, node_id, locals, effs)?;
1168                        self.unify_with_record_coercion(&at, t).map_err(|e| mismatch_err(node_id, e, &self.u, vec![format!("constructor `{}` arg {}", name, i + 1)]))?;
1169                    }
1170                }
1171                Ok(Ty::Con(owning, con_args))
1172            }
1173            (None, _) => Err(TypeError::ArityMismatch {
1174                at_node: node_id.into(), expected: 0, got: args.len(),
1175            }),
1176        }
1177    }
1178
1179    fn bind_pattern(
1180        &mut self,
1181        pat: &a::Pattern,
1182        ty: &Ty,
1183        locals: &mut IndexMap<String, Ty>,
1184        node_id: &str,
1185    ) -> Result<(), TypeError> {
1186        match pat {
1187            a::Pattern::PWild => Ok(()),
1188            a::Pattern::PVar { name } => {
1189                locals.insert(name.clone(), ty.clone());
1190                Ok(())
1191            }
1192            a::Pattern::PLiteral { value } => {
1193                let lt = lit_type(value);
1194                self.unify_with_record_coercion(&lt, ty).map_err(|e| mismatch_err(node_id, e, &self.u, vec!["in pattern".into()]))?;
1195                Ok(())
1196            }
1197            a::Pattern::PConstructor { name, args } => {
1198                // Re-use constructor logic but in pattern position.
1199                let owning = self.type_env.ctor_to_type.get(name).cloned()
1200                    .ok_or_else(|| TypeError::UnknownVariant {
1201                        at_node: node_id.into(), constructor: name.clone(),
1202                    })?;
1203                let def = self.type_env.types.get(&owning).cloned().unwrap();
1204                let mut subst = IndexMap::new();
1205                let mut con_args = Vec::new();
1206                for (i, _) in def.params.iter().enumerate() {
1207                    let fresh = self.u.fresh();
1208                    subst.insert(i as u32, fresh.clone());
1209                    con_args.push(fresh);
1210                }
1211                let con_ty = Ty::Con(owning.clone(), con_args);
1212                self.unify_with_record_coercion(&con_ty, ty).map_err(|e| mismatch_err(node_id, e, &self.u, vec![format!("constructor pattern `{}`", name)]))?;
1213                let payload = match &def.kind {
1214                    TypeDefKind::Union(v) => v.get(name).cloned().flatten(),
1215                    _ => None,
1216                };
1217                match (payload, args.as_slice()) {
1218                    (None, []) => Ok(()),
1219                    (Some(payload), args) => {
1220                        let inst = subst_vars(&payload, &subst, &IndexMap::new());
1221                        if args.len() == 1 {
1222                            self.bind_pattern(&args[0], &inst, locals, node_id)?;
1223                        } else if let Ty::Tuple(items) = inst {
1224                            for (a, t) in args.iter().zip(items.iter()) {
1225                                self.bind_pattern(a, t, locals, node_id)?;
1226                            }
1227                        }
1228                        Ok(())
1229                    }
1230                    (None, _) => Err(TypeError::ArityMismatch {
1231                        at_node: node_id.into(), expected: 0, got: args.len(),
1232                    }),
1233                }
1234            }
1235            a::Pattern::PRecord { fields } => {
1236                // Unfold a record-aliased Con (`type Bands = { ... }`)
1237                // so a structural `{ idea: pat, ... }` pattern can match
1238                // a nominal-typed scrutinee, mirror of #79's literal
1239                // coercion at every position.
1240                let resolved = self.unfold_record_alias(self.u.resolve(ty));
1241                let rec = match resolved {
1242                    Ty::Record(r) => r,
1243                    _ => return Err(TypeError::TypeMismatch {
1244                        at_node: node_id.into(),
1245                        expected: "record".into(),
1246                        got: ty.pretty(),
1247                        context: vec!["in record pattern".into()],
1248                    }),
1249                };
1250                for f in fields {
1251                    let ft = rec.get(&f.name).cloned()
1252                        .ok_or_else(|| TypeError::UnknownField {
1253                            at_node: node_id.into(),
1254                            record_type: Ty::Record(rec.clone()).pretty(),
1255                            field: f.name.clone(),
1256                        })?;
1257                    self.bind_pattern(&f.pattern, &ft, locals, node_id)?;
1258                }
1259                Ok(())
1260            }
1261            a::Pattern::PTuple { items } => {
1262                let resolved = self.u.resolve(ty);
1263                let tup = match resolved {
1264                    Ty::Tuple(t) => t,
1265                    Ty::Var(_) => {
1266                        let fresh: Vec<Ty> = items.iter().map(|_| self.u.fresh()).collect();
1267                        let tup_ty = Ty::Tuple(fresh.clone());
1268                        self.unify_with_record_coercion(&tup_ty, ty).map_err(|e| mismatch_err(node_id, e, &self.u, vec!["in tuple pattern".into()]))?;
1269                        fresh
1270                    }
1271                    other => return Err(TypeError::TypeMismatch {
1272                        at_node: node_id.into(),
1273                        expected: "tuple".into(),
1274                        got: other.pretty(),
1275                        context: vec!["in tuple pattern".into()],
1276                    }),
1277                };
1278                if tup.len() != items.len() {
1279                    return Err(TypeError::ArityMismatch {
1280                        at_node: node_id.into(), expected: tup.len(), got: items.len(),
1281                    });
1282                }
1283                for (p, t) in items.iter().zip(tup.iter()) {
1284                    self.bind_pattern(p, t, locals, node_id)?;
1285                }
1286                Ok(())
1287            }
1288        }
1289    }
1290}
1291
1292fn lit_type(l: &a::CLit) -> Ty {
1293    match l {
1294        a::CLit::Int { .. } => Ty::int(),
1295        a::CLit::Float { .. } => Ty::float(),
1296        a::CLit::Str { .. } => Ty::str(),
1297        a::CLit::Bytes { .. } => Ty::bytes(),
1298        a::CLit::Bool { .. } => Ty::bool(),
1299        a::CLit::Unit => Ty::Unit,
1300    }
1301}
1302
1303fn instantiate(s: &Scheme, u: &mut Unifier) -> Ty {
1304    let mut ty_subst = IndexMap::new();
1305    for v in &s.vars { ty_subst.insert(*v, u.fresh()); }
1306    let mut eff_subst = IndexMap::new();
1307    for v in &s.eff_vars { eff_subst.insert(*v, u.fresh_eff_id()); }
1308    subst_vars(&s.ty, &ty_subst, &eff_subst)
1309}
1310
1311fn subst_vars(
1312    t: &Ty,
1313    subst: &IndexMap<TyVarId, Ty>,
1314    eff_subst: &IndexMap<u32, u32>,
1315) -> Ty {
1316    match t {
1317        Ty::Var(v) => subst.get(v).cloned().unwrap_or_else(|| Ty::Var(*v)),
1318        Ty::Prim(_) | Ty::Unit | Ty::Never => t.clone(),
1319        Ty::List(inner) => Ty::List(Box::new(subst_vars(inner, subst, eff_subst))),
1320        Ty::Tuple(items) => Ty::Tuple(items.iter().map(|t| subst_vars(t, subst, eff_subst)).collect()),
1321        Ty::Record(fs) => {
1322            let mut out = IndexMap::new();
1323            for (k, v) in fs { out.insert(k.clone(), subst_vars(v, subst, eff_subst)); }
1324            Ty::Record(out)
1325        }
1326        Ty::Con(n, args) => Ty::Con(n.clone(),
1327            args.iter().map(|t| subst_vars(t, subst, eff_subst)).collect()),
1328        Ty::Function { params, effects, ret } => {
1329            // Refresh the effect-row variable if it's quantified in the
1330            // scheme; concrete kinds carry through unchanged.
1331            let new_effects = EffectSet {
1332                concrete: effects.concrete.clone(),
1333                var: effects.var.and_then(|v| eff_subst.get(&v).copied()).or(effects.var),
1334            };
1335            Ty::Function {
1336                params: params.iter().map(|t| subst_vars(t, subst, eff_subst)).collect(),
1337                effects: new_effects,
1338                ret: Box::new(subst_vars(ret, subst, eff_subst)),
1339            }
1340        }
1341    }
1342}
1343
1344fn mismatch_err(node_id: &str, e: UnifyError, u: &Unifier, context: Vec<String>) -> TypeError {
1345    match e {
1346        UnifyError::Mismatch { a, b } => TypeError::TypeMismatch {
1347            at_node: node_id.into(),
1348            expected: u.resolve(&b).pretty(),
1349            got: u.resolve(&a).pretty(),
1350            context,
1351        },
1352        UnifyError::Infinite { .. } => TypeError::InfiniteType { at_node: node_id.into() },
1353        UnifyError::EffectMismatch { a, b } => {
1354            // Render effect mismatches as a type-mismatch in compact
1355            // form, e.g. `[net]` vs `[]`. Avoids inventing a new
1356            // TypeError variant + wire format right now.
1357            let render = |e: &EffectSet| -> String {
1358                let mut parts: Vec<String> = e.concrete.iter()
1359                    .map(crate::types::EffectKind::pretty).collect();
1360                if let Some(v) = e.var { parts.push(format!("?e{}", v)); }
1361                if parts.is_empty() { "[]".into() } else { format!("[{}]", parts.join(", ")) }
1362            };
1363            TypeError::TypeMismatch {
1364                at_node: node_id.into(),
1365                expected: render(&b),
1366                got: render(&a),
1367                context,
1368            }
1369        }
1370    }
1371}