Skip to content

Conversation

@CMI-James
Copy link

@CMI-James CMI-James commented Jan 23, 2026

Description

Closes #72

Changes proposed

What were you told to do?

I was tasked with implementing Error Type Poisoning to prevent cascading errors in the type checker. This involved adding a TypeInfoKind::Error variant similar to rustc's TyKind::Error and updating the type checker to gracefully handle these error types.

What did I do?

Implemented TypeInfoKind::Error

  • Added Error variant to the TypeInfoKind enum in core/type-checker/src/type_info.rs.
  • Updated Display implementation to show {unknown} for error types.
  • Added is_error() helper method to TypeInfo.
  • Updated substitute and has_unresolved_params to handle Error variants neutrally.

Updated Type Checker Logic

  • Modified core/type-checker/src/type_checker.rs to use TypeInfoKind::Error for type poisoning.
  • Updated infer_expression for Identifier, Struct initialization, MemberAccess, TypeMemberAccess, and FunctionCall to return TypeInfoKind::Error upon lookup failure instead of None or continuing with invalid types.
  • Updated type mismatch checks in Statement::Assign, Statement::Return, Statement::If, Statement::Loop, and others to suppress errors if either the expected or found type is Error.
  • Updated Expression::ArrayIndexAccess, Expression::PrefixUnary, and Expression::Binary to propagate Error types and suppress secondary errors (e.g., "expected array type" when the array expression itself failed).

Added Tests

  • Created tests/src/type_checker/error_type_poisoning_tests.rs with comprehensive tests covering:
    • TypeInfoKind::Error variant behavior (is_error(), Display, substitute, has_unresolved_params)
    • Cascading error suppression (undeclared variables, undefined structs, undefined functions, binary/unary operations, conditions)
    • Error propagation through assignments, struct initialization, chained member access, method calls, function arguments
    • Error types in complex expressions (nested binary, array literals, conditionals, assertions)
    • Error type equality

Benefits

  • Prevents cascading errors when a type lookup fails.
  • Allows type checking to continue more gracefully.
  • Aligns with rustc's error handling model.

AI Disclosure

AI tools (Claude) were used to assist with the following parts of this implementation:

AI-generated code (reviewed and tested before submission):

  • core/type-checker/src/type_info.rs: Added TypeInfoKind::Error variant, is_error() method, Display match arm for Error, and handling in substitute/has_unresolved_params
  • core/type-checker/src/type_checker.rs: Updated type mismatch checks to skip errors when either type is Error, modified infer_expression branches to return TypeInfoKind::Error on lookup failures
  • tests/src/type_checker/error_type_poisoning_tests.rs: AI assisted with test structure and test case implementation

Human contributions:

  • Overall design decision to use rustc-style error poisoning
  • Review and validation of all generated code
  • Testing the implementation against the existing test suite

Check List (Check all the applicable boxes)

🚨Please review the contribution guideline for this repository.

  • My code follows the code style of this project.
  • This PR does not contain plagiarized content.
  • The title and description of the PR is clear and explains the approach.
  • I am making a pull request against the main branch (left side).
  • My commit messages styles matches our requested structure.
  • My code additions will fail neither code linting checks nor unit test.
  • I am only making changes to files I was requested to.

Screenshots/Videos

Screenshot 2026-01-22 at 13 49 02

- Add TypeInfoKind::Error variant to represent unresolved or invalid types
- Update TypeChecker to gracefully handle TypeInfoKind::Error by suppressing cascading errors
- Update expression inference (Identifier, Struct, MemberAccess, FunctionCall, etc.) to return Error type on failure instead of None/cascading
- Prevent spurious type mismatch errors when one of the types is Error
…soning)

- Add tests for TypeInfoKind::Error variant behavior (is_error, Display, substitute, has_unresolved_params)
- Add tests for cascading error suppression with undeclared variables, undefined structs/functions
- Add tests for error propagation through assignments, member access, method calls, function arguments
- Add tests for error types in complex expressions (binary ops, arrays, conditionals)
- Register error_type_poisoning_tests module in mod.rs
@SurfingBowser
Copy link
Contributor

As pointed out in the contributor guide

State clearly in the PR description which parts of the code were generated by AI tools

@CMI-James
Copy link
Author

@SurfingBowser updated.

@CMI-James
Copy link
Author

@SurfingBowser kindly review

@0xGeorgii 0xGeorgii requested a review from Copilot January 27, 2026 01:24
Copy link

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

This PR implements Error Type Poisoning to prevent cascading errors in the type checker, following rustc's TyKind::Error model. When a type lookup fails, the type checker now uses TypeInfoKind::Error to suppress subsequent type mismatch errors that would otherwise cascade from the initial failure.

Changes:

  • Added TypeInfoKind::Error variant with supporting methods (is_error(), display as {unknown})
  • Updated type checker to return Error types on lookup failures and suppress cascading type mismatch errors
  • Added comprehensive test suite covering error poisoning behavior across various scenarios

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

File Description
tests/src/type_checker/mod.rs Registered new error type poisoning test module
tests/src/type_checker/error_type_poisoning_tests.rs Comprehensive test suite verifying error type behavior and cascading error suppression
core/type-checker/src/type_info.rs Added Error variant to TypeInfoKind enum with display and helper methods
core/type-checker/src/type_checker.rs Updated type checking logic to use error types for poisoning and suppress cascading errors

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@CMI-James
Copy link
Author

@0xGeorgii done, I Implemented Error variant now takes a String message.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think this is the only case where None is returned from this function. Is there a way to handle this better and make the function not return Option?

Copy link
Contributor

Choose a reason for hiding this comment

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

Any thoughts on this? @CMI-James

@CMI-James
Copy link
Author

@0xGeorgii kindly review

@0xGeorgii
Copy link
Contributor

@CMI-James thank for your contribution, here are several comments:

Critical Issues

  1. Loop/If/Assert still emit cascading errors when condition is Error type

In type_checker.rs, the Statement::Loop, Statement::If, and Statement::Assert handlers all have the same bug pattern:

 let cond_type = self.infer_expression(condition, ctx);
 if !cond_type.is_error() {
     if !cond_type.is_bool() {
         self.errors.push(TypeCheckError::TypeMismatch { ... });
     }
 } else {
     // BUG: Still pushes a TypeMismatch error when cond_type is Error!
     self.errors.push(TypeCheckError::TypeMismatch {
         expected: TypeInfo::boolean(),
         found: TypeInfo::default(),  // Also wrong: uses Unit instead of the actual Error type
         context: TypeMismatchContext::Condition,
         location: ...,
     });
 }

The else branch fires when cond_type.is_error() is true. Error poisoning should suppress the cascading error, not emit one. The entire else block
should be removed (or be a no-op). This bug appears 3 times:

  • Statement::Loop (around line 650-673 in the diff)
  • Statement::If (around line 677-699 in the diff)
  • Statement::Assert (around line 759-778 in the diff)

Fix: Remove the else block entirely. When cond_type.is_error(), do nothing.

  1. Binary logical operators (And/Or) cascade errors
 OperatorKind::And | OperatorKind::Or => {
     if left_type.is_bool() && right_type.is_bool() {
         TypeInfo::boolean()
     } else {
         self.errors.push(TypeCheckError::InvalidBinaryOperand { ... }); // CASCADE!
         TypeInfo::error("Invalid binary operand")
     }
 }

When one operand is Error, is_bool() returns false, so this falls into the error branch and emits InvalidBinaryOperand -- a cascading error. Should
guard with is_error() checks.

  1. Binary arithmetic operators cascade errors
 OperatorKind::Add | OperatorKind::Sub | ... => {
     if !left_type.is_number() || !right_type.is_number() {
         self.errors.push(TypeCheckError::InvalidBinaryOperand { ... }); // CASCADE!
     }
     // ...
 }

Error types are not numbers, so this fires when one operand is Error. Same cascade issue.

  1. Duplicate BinaryOperandTypeMismatch errors for non-Error cases

A type-mismatch check before the operator-specific match:

 if !left_type.is_error() && !right_type.is_error() && left_type != right_type {
     self.errors.push(TypeCheckError::BinaryOperandTypeMismatch { ... });
 }

But the arithmetic arm ALSO checks:

if left_type != right_type {
     self.errors.push(TypeCheckError::BinaryOperandTypeMismatch { ... });
 }

For non-Error mismatched arithmetic types, BOTH checks fire, producing duplicate errors. The original code only had the second check (inside the if
let (Some, Some) block). This needs to be restructured -- either remove the first general check or remove the per-operator check.

  1. Function call: arguments not inferred for undefined functions (error recovery regression)

Old code for undefined functions:

 if let Some(arguments) = &function_call_expression.arguments {
     for arg in arguments {
         self.infer_expression(&arg.1.borrow(), ctx);
     }
 }
 return None;

New code:

 return TypeInfo::error("Poisoned type from error");

The new code skips argument inference entirely. This means errors within arguments of an undefined function call won't be detected. This is a
regression in error recovery quality. The argument inference should be preserved before returning the Error type.

Significant Issues

  1. Error types stored in TypedContext

TypeInfo::error(...) is stored into the typed context via set_node_typeinfo() for identifiers, struct expressions, and member accesses.
Downstream phases (codegen, wasm-to-v) would see these Error types if they ever accessed the typed context. This should be safe since the type
checker bails with errors before downstream phases run, but it would be more robust to either:

  • Document this invariant clearly, or
  • Only store Error types when necessary for caching (not as a general practice)
  1. TypeInfo::error() messages are informal and inconsistent

The error messages are ad-hoc strings like "Poisoned type from error", "Expected array type", "Undefined identifier", "Method not found", etc. These strings are not user-facing (they go into TypeInfoKind::Error(String) for internal tracking), but they're inconsistent in style. Some describe what happened, some describe what was expected. Consider using a more structured approach or at minimum a consistent naming convention.

  1. Test assertions are weak

Tests primarily check that error messages contain certain keywords:
assert!(error_msg.contains("unknown_var"), "Should report undeclared variable: {}", error_msg);

But they don't verify:

  • Exact number of errors (to confirm cascading errors are actually suppressed)
  • That specific cascading error types are absent
  • The string "{error}" check is fragile -- cascading errors might not contain that literal

Stronger tests should count errors or check for absence of specific TypeCheckError variants.

Minor Issues

  1. Empty array literal returns Error type

Literal::Array with no elements now returns TypeInfo::error("Empty array literal") instead of None. This changes semantics -- an empty array might not be a type error per se.

  1. #[cfg(test)] inside a test-only file

The test file error_type_poisoning_tests.rs wraps its module in #[cfg(test)]. Since it's already under tests/src/, the #[cfg(test)] is redundant.

  1. Inconsistent is_bool() helper usage

TypeInfo::boolean() constructor is used in some places but cond_type.is_bool() for checks. The PR also introduces a pattern where Error types pass through is_bool() / is_number() checks returning false, which is correct but means every type check site must independently guard against Error types.

- Fixed cascading errors in Loop, If, and Assert statements
- Fixed cascading errors in binary logical and arithmetic operators
- Deduplicated BinaryOperandTypeMismatch errors
- Restored argument inference for undefined function calls
- Fixed empty array literal returning Error type
- Removed redundant #[cfg(test)] from test file
@CMI-James
Copy link
Author

@0xGeorgii review.

@0xGeorgii
Copy link
Contributor

@CMI-James thanks for addressing the previous round. The critical bugs from round 1 (Loop/If/Assert cascading, binary operator guards, duplicate BinaryOperandTypeMismatch, argument inference for undefined functions) are fixed. there are remaining issues:

Critical Issues

  1. Arithmetic binary ops return left_type.clone() -- asymmetric error propagation
  OperatorKind::Add | OperatorKind::Sub | ... => {
      if !left_type.is_error() && !right_type.is_error() {
          if !left_type.is_number() || !right_type.is_number() {
              self.errors.push(TypeCheckError::InvalidBinaryOperand { ... });
          }
      }
      left_type.clone() // <-- always returns left_type
  }

When right_type is Error but left_type is valid (e.g. 5 + unknown_var), this returns i32 -- a valid type -- even though the expression is unresolved. Downstream code will treat this as well-typed.
The reverse (unknown_var + 5) correctly returns Error. These two semantically identical expressions produce different type results.

Fix: Return TypeInfo::error(...) when either operand is Error, similar to the And/Or arm:

  if left_type.is_error() || right_type.is_error() {
      return if left_type.is_error() { left_type.clone() } else { right_type.clone() };
  }
  1. Method call on error receiver skips argument inference

When receiver_type.is_error(), the code returns early without inferring function call arguments. The old code explicitly inferred all arguments even when the receiver type was None (for error
recovery). This means unknown_obj.method(also_bad_arg) now only reports unknown_obj, losing the error for also_bad_arg.

Fix: Infer arguments before returning the error type, matching the old None branch behavior.

  1. Error(String) equality semantics

TypeInfoKind derives PartialEq, so Error("foo") != Error("bar"). If two different error-producing expressions are compared (e.g. in assignment), the is_error() guards prevent issues today, but this
is fragile -- missing a single guard creates a spurious mismatch between two error messages.

Industry standard (rustc, rust-analyzer, chalk) uses either a unit variant Error with no payload, or Error(ErrorGuaranteed) with a zero-sized proof token. The String payload creates:

  • Fragile equality semantics
  • Redundant diagnostic data (error messages already live in Vec)
  • Heap allocation overhead per error type

Recommendation: Use TypeInfoKind::Error (unit variant, no payload). If you want the error message for debugging, it's already captured in self.errors.

Significant Issues

  1. Comparison operators return Bool even with Error operands

Eq/Ne/Lt/Le/Gt/Ge always return TypeInfo::boolean(), even when one or both operands are Error. This diverges from the pattern used by And/Or and arithmetic. The practical impact is low (comparison
result is always Bool), but it loses the error signal for consistency. Consider propagating Error when either operand is Error, or document this as intentional.

  1. Tests don't verify error counts

The test file checks string contents (error_msg.contains("keyword")) but never verifies the total number of errors. For a feature specifically about suppressing cascading errors, tests should split
error_msg by "; " and assert the count. Example:

let errors: Vec<&str> = error_msg.split("; ").collect();
assert_eq!(errors.len(), 1, "Should produce exactly 1 error, got: {:?}", errors);

  1. Uzumaki expression returns Error silently without diagnostic

Expression::Uzumaki returns TypeInfo::error("Uzumaki type not inferred") but pushes no TypeCheckError. The user gets no diagnostic. If this path is unreachable (uzumaki type is always pre-set), use
unreachable!() or debug_assert!. If it is reachable, push a diagnostic.

  1. Dead code guards on declared types

return_type.is_error() in Statement::Return and target_type.is_error() in Statement::VariableDefinition can never be true -- these types come from TypeInfo::new() / TypeInfo::new_with_type_params()
which never produce Error. Not harmful, but misleading. Consider removing or adding a comment explaining these are defensive guards.

Style / Convention Issues

  1. Double space in Statement::If: if !cond_type.is_error() && !cond_type.is_bool() -- extra space before &&.
  2. #[must_use] on error() and is_error() should include a reason string per CONTRIBUTING.md: #[must_use = "this is a pure check with no side effects"] (matching is_signed_integer()).
  3. Missing #[cfg(test)] before mod error_type_poisoning_tests in the test file -- other test files in the same directory use this attribute.
  4. Error message strings are inconsistent in style: "Poisoned type from error", "Expected array type", "Undefined identifier", "Method not found" -- some describe what happened, some describe what
    was expected. Use a consistent convention.
  5. Missing doc comments on error() and is_error() methods -- other similar methods like is_signed_integer() have doc comments.

Copy link
Contributor

@0xGeorgii 0xGeorgii left a comment

Choose a reason for hiding this comment

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

Please address issues from my comment

Critical fixes:
- Arithmetic ops now propagate Error symmetrically (5+unknown == unknown+5)
- Method call on error receiver now infers arguments before returning
- Changed Error(String) to unit variant Error for proper equality semantics

Significant fixes:
- Comparison operators now propagate Error for consistency
- Uzumaki expression now emits diagnostic when type not inferred
- Added comments explaining defensive guards on declared types
- Tests now verify error counts using split('; ')

Style fixes:
- Fixed double space in Statement::If
- Added #[must_use] reason strings to error() and is_error()
- Added #[cfg(test)] attribute to test module
- Added doc comments to error() and is_error() methods
@CMI-James
Copy link
Author

@0xGeorgii review

@CMI-James
Copy link
Author

@0xGeorgii

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.

Consider Error Type Poisoning

3 participants