Skip to content

feat(typechecker): support row polymorphism for dicts via Record type#1364

Merged
harehare merged 1 commit intomainfrom
feat/typechecker-row-polymorphism
Feb 28, 2026
Merged

feat(typechecker): support row polymorphism for dicts via Record type#1364
harehare merged 1 commit intomainfrom
feat/typechecker-row-polymorphism

Conversation

@harehare
Copy link
Copy Markdown
Owner

  • Dict literals with string keys are now inferred as Record types with per-field value types
  • Implements row polymorphism: Record unification, Dict↔Record compatibility, field access resolution
  • Updates constraint generation, unification, and type display logic
  • Adds/updates tests for Record type inference, unification, and bracket access

- Dict literals with string keys are now inferred as Record types with per-field value types
- Implements row polymorphism: Record unification, Dict↔Record compatibility, field access resolution
- Updates constraint generation, unification, and type display logic
- Adds/updates tests for Record type inference, unification, and bracket access
Copilot AI review requested due to automatic review settings February 28, 2026 14:54
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds row-polymorphic Record types to mq-typechecker so dict literals with statically-known keys can carry per-field value types, enabling more precise unification and field/bracket access type resolution.

Changes:

  • Introduces Type::Record + Type::RowEmpty and updates type display/matching utilities for row-polymorphic records.
  • Extends unification and constraint generation to support Record↔Record unification and Record↔Dict compatibility, plus selector/bracket access typing hooks.
  • Adds/updates tests covering record inference, nested records, empty dict behavior, and bracket-access type checking.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
crates/mq-typechecker/src/types.rs Adds Record/RowEmpty and extends matching/scoring + display formatting.
crates/mq-typechecker/src/unify.rs Implements record unification and Record↔Dict compatibility in the unifier.
crates/mq-typechecker/src/constraint.rs Infers Record types for dict literals with known keys; adds selector typing and defers bracket-access resolution.
crates/mq-typechecker/src/infer.rs Adds deferred record-access tracking and Record-aware type resolution.
crates/mq-typechecker/src/lib.rs Runs a post-unification pass to resolve deferred record field accesses and re-unifies.
crates/mq-typechecker/tests/integration_test.rs Adds integration tests for record inference/unification behavior and bracket access typing.
crates/mq-typechecker/tests/type_errors_test.rs Updates heterogeneous dict test messaging/comments for row polymorphism.

Comment on lines +128 to +129
// All record keys are strings → unify k with String
unify(ctx, k, &Type::String, range);
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In Record ↔ Dict unification, the dict key type is forced to string (unify(ctx, k, &Type::String, ...)). That makes uses like get({"a": 1}, :a) (valid at runtime since dict ops accept symbol keys too) fail typechecking because :a is symbol.

Consider allowing both key types here (e.g. unify k with Union(string | symbol)), or avoid constraining k at all and instead ensure record field-name normalization is consistent with both access forms.

Suggested change
// All record keys are strings → unify k with String
unify(ctx, k, &Type::String, range);
// Record keys are represented as strings, but we intentionally do not constrain
// the dict key type here so that dicts with symbol keys remain compatible.

Copilot uses AI. Check for mistakes.
Comment on lines +211 to +223
let accesses = ctx.take_deferred_record_accesses();
if accesses.is_empty() {
return false;
}

let mut resolved_any = false;
for access in &accesses {
// Resolve the variable's type (should now be Record after unification)
let var_ty = match ctx.get_symbol_type(access.def_id).cloned() {
Some(ty) => ctx.resolve_type(&ty),
None => continue,
};

Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolve_record_field_accesses consumes the entire deferred access list (take_deferred_record_accesses) and then silently continues when the variable type isn't available / isn't a Record. Any unresolved accesses are dropped and never retried later (e.g. if the variable becomes a Record only after propagate_user_call_returns).

Consider re-queuing unresolved accesses back into the context (or running this pass after all other post-unification propagation, possibly in a small fixpoint loop).

Copilot uses AI. Check for mistakes.
{
// Bind the bracket access expression's type to the field type
let call_ty = ctx.get_or_create_symbol_type(access.call_symbol_id);
ctx.add_constraint(constraint::Constraint::Equal(call_ty, field_ty.clone(), None));
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When binding the bracket-access expression type to the resolved field type, the new Constraint::Equal is created with None range. This means any resulting mismatch/arity diagnostics won't have a source span, making the error output less actionable.

Consider storing the original access Range in DeferredRecordAccess and threading it through here.

Suggested change
ctx.add_constraint(constraint::Constraint::Equal(call_ty, field_ty.clone(), None));
ctx.add_constraint(constraint::Constraint::Equal(
call_ty,
field_ty.clone(),
access.range.clone(),
));

Copilot uses AI. Check for mistakes.
Comment on lines +229 to +239
// Records match if common fields can match and rests can match
(Type::Record(f1, r1), Type::Record(f2, r2)) => {
// Common fields must match
for (k, v1) in f1 {
if let Some(v2) = f2.get(k)
&& !v1.can_match(v2)
{
return false;
}
}
r1.can_match(r2)
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type::can_match for Record only checks that common fields match and then returns r1.can_match(r2). This makes two closed records with disjoint/extra fields (e.g. {a: int} vs {b: string} with RowEmpty tails) incorrectly considered matchable, which can skew overload resolution.

Consider factoring in non-common fields: if either tail is RowEmpty, the other side must not have extra fields; if tails are open, ensure extras can be accommodated by the tail.

Suggested change
// Records match if common fields can match and rests can match
(Type::Record(f1, r1), Type::Record(f2, r2)) => {
// Common fields must match
for (k, v1) in f1 {
if let Some(v2) = f2.get(k)
&& !v1.can_match(v2)
{
return false;
}
}
r1.can_match(r2)
// Records match if common fields can match and row tails are compatible.
(Type::Record(f1, r1), Type::Record(f2, r2)) => {
// Common fields must match
for (k, v1) in f1 {
if let Some(v2) = f2.get(k) {
if !v1.can_match(v2) {
return false;
}
}
}
match (&**r1, &**r2) {
// Both records are closed: they must have exactly the same set of fields.
(Type::RowEmpty, Type::RowEmpty) => {
if f1.len() != f2.len() {
return false;
}
for key in f1.keys() {
if !f2.contains_key(key) {
return false;
}
}
true
}
// Left record is closed: right record must not have extra concrete fields.
(Type::RowEmpty, _) => {
for key in f2.keys() {
if !f1.contains_key(key) {
return false;
}
}
r1.can_match(r2)
}
// Right record is closed: left record must not have extra concrete fields.
(_, Type::RowEmpty) => {
for key in f1.keys() {
if !f2.contains_key(key) {
return false;
}
}
r1.can_match(r2)
}
// Both records have non-empty row tails: defer to the row matcher.
_ => r1.can_match(r2),
}

Copilot uses AI. Check for mistakes.
count += 1;
}
}
let field_score = if count > 0 { total / count } else { 10 };
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

match_score for Record can end up very high even when there are no common fields because it falls back to field_score = 10 and then adds rest_score (often 100 for RowEmpty) plus 20. This makes unrelated records score better than they should, again impacting overload resolution.

Consider heavily penalizing the count == 0 case (especially when both tails are RowEmpty), or incorporate symmetric field-set similarity into the score.

Suggested change
let field_score = if count > 0 { total / count } else { 10 };
if count == 0 {
// No shared fields.
// Distinguish between truly empty records and records with disjoint field sets.
let rest_score = r1.match_score(r2).unwrap_or(0);
if f1.is_empty() && f2.is_empty() {
// Identical empty records: rely on the row-tail score and keep the
// original structural bonus so that `{}` matches `{}` strongly.
return Some(rest_score + 20);
}
// There are fields present, but none in common. Heavily penalize this
// situation so that unrelated records do not win overload resolution
// purely because their row tails match (e.g. both `RowEmpty`).
let penalized_rest = rest_score / 10;
return Some(penalized_rest + 5);
}
let field_score = total / count;

Copilot uses AI. Check for mistakes.
@harehare harehare merged commit c2bfdbb into main Feb 28, 2026
10 checks passed
@harehare harehare deleted the feat/typechecker-row-polymorphism branch February 28, 2026 23:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants