MEP 10. Known Gaps and Weakness Review
| Field | Value |
|---|---|
| MEP | 10 |
| Title | Known Gaps and Weakness Review |
| Author | Mochi core |
| Status | Informational |
| Type | Informational |
| Created | 2026-05-08 |
Abstract
This MEP is the "uncomfortable truths" file. It catalogues every place where Mochi today is unsound, surprising, incomplete, or simply mis-sold. Each entry has the evidence (file and line), an impact assessment, and either a fix plan or an explicit "by design" note.
Motivation
A fixture pinned to current behaviour is not the same as endorsement. Some entries below are captured by fixtures so that a future fix is a deliberate breaking change rather than an accidental drift. The tier system labels how seriously each gap threatens the "type safe" claim.
Specification
Tier A: critical for the "type safe" claim
A1. AnyType is a true top type
Evidence. types/check.go:148-157 for the unify rule that lets AnyType match in either direction. Most builtins are declared with AnyType{} parameters at lines 393 to 562.
Impact. A value typed any can be passed to a function expecting int, indexed as a list, or compared with anything. There is no explicit cast required.
Plan.
- Capture the current behaviour in a fixture (
tests/types/valid/any_top_silent_widen.mochi). - Audit which builtins truly need
anyand rewrite the rest with parametric polymorphism once MEP 12 lands. - Tighten
unifysoanyrequires an explicitascast in either direction.
A2. null is a value of any type
Evidence. types/check.go:1708. The literal null is typed AnyType{} rather than producing an OptionType.
Impact. let x: int = null type checks today. Dereferencing x in arithmetic is a runtime error.
Plan.
- Wire
OptionTypeinto the inference ofnull. - Introduce a
T?syntactic shorthand forOptionType{T}. - Add a check that requires explicit unwrap (
?ormatch) before use as the underlying type.
A3. Mutation through immutable bindings via aliasing
Evidence. types/check.go:858-868 in the AssignStmt walk. The mutability flag is consulted before any index or field walk, so the direct path let xs = [1]; xs[0] = 2 and let p = Point{x: 1}; p.x = 2 are both rejected with T024 today. Pinned by tests/types/errors/mutate_through_let_index.mochi and tests/types/errors/mutate_through_let_field.mochi.
The remaining hole is aliasing. Copying a let-bound list (or struct, or map) into a var binds the same underlying storage at runtime, so a write through the var mutates the let too. tests/types/valid/mutate_through_let_alias.mochi pins the type checker accepting the alias write; the runtime read after the write returns the mutated value.
Impact. let does not guarantee that the value behind the binding does not change. A defensive reader cannot trust that a let-bound list seen earlier in a function still has the same elements after an unrelated call that received a copy of the binding.
Plan.
- Pick a discipline. Two candidates: copy-on-write at the alias boundary (let-bound aggregates clone before binding into a
var), or invariance under aliasing where assigning a let-bound aggregate to avarof the same type is a checker error. - If invariance, add error code (next free)
T0xx: cannot alias an immutable aggregate into a mutable binding. - Move the alias fixture from
valid/toerrors/once the rule lands.
A4. Match exhaustiveness not enforced
Evidence. The match check loop in types/check.go (lines around 2209 to 2284) types each arm but does not check that the union of arm patterns covers every variant of a UnionType scrutinee.
Impact. Removing a variant from a union does not surface as a compile error in code that matches on it. Users discover the gap at runtime.
Plan.
- Compute the variant coverage during match check.
- Allow a single wildcard or identifier pattern to cover the remainder, and warn (not error) if it shadows reachable cases.
- Emit a new error code for missing variants.
A5. as cast is unchecked
Evidence. types/check.go:1687-1688 resolves the target type and returns it. There is no compatibility check.
Impact. (5 as string).len type checks. The runtime then either crashes or silently coerces, depending on the path.
Plan.
- Define a small subtype check
castOk(from, to)that allows numeric tower casts, union to variant casts (with a runtime discriminator check), andto anyalways. - Reject other casts at type check time.
Tier B: surprising or incomplete
B1. Integer division mismatch between checker and runtime
Evidence. types/infer.go:116-118 says int / int produces int. At runtime 5 / 2 evaluates to 2.5. Probed against the v0.10.81 binary on 2026-05.
Impact. let x: int = 5 / 2; print(x) is accepted by the type checker and prints 2.5. The static type and the runtime value disagree. A later operation x + 1 then prints 3, which suggests an implicit narrowing back to int at the use site. The combination is genuinely unsound: the checker promises int and the program observes a float value flowing through.
Plan. Decide policy first.
- Path A: rational division. Make
/always returnfloatfor numeric operands. Document. UpdateinferBinaryTypeto drop the int/int special case. Add adivbuiltin or a//operator for truncating division. - Path B: truncating division. Make the runtime truncate, matching the checker. Add a fixture confirming
5 / 2 == 2.
Path A matches the runtime today. We recommend it.
B2. Operator precedence reduction is order-dependent on numeric mixes
Evidence. types/infer.go:120-138. The mix rules check isInt64 before isFloat and that ordering can produce different result types depending on operand position.
Impact. bigint - float and float - bigint may not produce the same type. Programs that rely on the result type mid-expression are fragile.
Plan. Replace the cascade with a small lattice and join semantics so the result depends only on the operand kinds, not on which side they appear on.
B3. List covariance without write protection
Evidence. types/check.go:172-189. unify(ListType{Int}, ListType{Any}) succeeds without restricting writes.
Impact. Once mutation through indices is tightened (A3), unsafe covariant aliasing becomes the next failure mode. Today the issue is masked because mutations slip through anyway.
Plan. After A3, treat list element type as invariant under aliasing. Allow read-only covariance only at expression positions that cannot be assigned through.
B4. Pure flag is set but never enforced
Evidence. The flag is set at builtin registration in types/check.go:393-562 and on user functions where the body is free of side effects. There is no rule that consumes it.
Impact. The flag is misleading. A programmer reading the code might trust it.
Plan. Either delete the flag, or add a small set of pure positions (struct field defaults, type alias arguments, query plan predicates) where impure calls are rejected.
B5. Generic functions parse but do not unify across calls
Evidence. TypeVar exists at types/check.go:104 and is used in unification but is not produced by inference of a function call's result. Each call site re-infers from the declared signature.
Impact. A function declared fun id<T>(x: T): T is callable but the caller always sees the type of x flow through the declaration in a loose way.
Plan. Wire TypeVar through inference. Build a small substitution solver so a call generates fresh variables and unifies arguments. Reject conflicting unifications with a new error code. See MEP 12.
B6. Query select fallback to any
Evidence. The aggregate dispatch in types/check.go:2401-2439 explicitly types count, sum, avg, min, max and falls through to general expression typing for everything else, often producing any.
Impact. A select foo(bar) where foo returns any does not raise even when bar is mistyped.
Plan. Extend the dispatch to keep stricter types and reject any in the projection unless explicitly cast.
B7. extern declarations are trust-the-author
Evidence. types/check.go:828-850 registers extern bindings without verifying the symbol exists in the import target.
Impact. Mismatch between declared type and actual symbol surfaces as a runtime failure, often deep inside a foreign call.
Plan. For Go and Python imports where we have introspection, validate the signature against the imported package.
B8. Recursive type definitions
Evidence. types/check.go:1179-1207 builds union types with a shared variants map so forward references resolve. Recursion in struct fields is permitted.
Impact. Soundness is not at issue but unbounded recursive structures can cause runtime memory issues. JSON serialisation can loop.
Plan. Document the policy. Add a fixture where a recursive linked list works. Add a runtime check for serialisation cycles.
B9. Shadowing without warning
Evidence. types/env.go:179, 187 allow rebinding a name in a child scope without diagnostic.
Impact. Subtle bugs. A user writes let x = ... in a nested block intending to shadow but accidentally reassigns.
Plan. Add an opt-in lint rule. Not a soundness issue.
Tier C: parser and surface quirks
C1. int | nil does not parse
Evidence. parser/parser.go:184-189. TypeRef does not list union as a constructor.
Impact. The natural way to write a nullable type fails.
Plan. Decide between two paths: a T? shorthand or full union types in TypeRef. Either resolves A2 once OptionType is wired through.
C2. Unary minus on RHS of comparison without parens
Evidence. parser/parser.go:53-56 documents the lexer rationale.
Impact. x == -1 requires parens. Inserting whitespace as x == - 1 does not help; the parser sees - followed by 1 as a missing-operand prefix and fails the same way. Surprising for new users.
Plan. Document in parser/README.md and in MEP 1. Long term, look at splitting the lexer rule so -1 is a number when not preceded by an ident or close bracket.
C3. Logic and stream features parse but do not run on the bytecode VM
Evidence. The bytecode compiler in runtime/vm/vm.go switch on statements does not include Agent, Stream, Emit, Fact, Rule, On, Intent.
Impact. Programs using these constructs run on the interpreter only. Building one of these programs into a bytecode binary silently removes the feature.
Plan. Either compile them or emit a clear error from the bytecode backend that says these constructs are interpreter only.
C4. The block comment regex does not nest
Evidence. parser/parser.go:49. /* ... */ matches the first */.
Impact. A literal */ inside a block comment terminates it early.
Plan. Document. Low priority.
C5. Reserved keywords accidentally steal user names
Evidence. The keyword regex at parser/parser.go:51 is enforced globally.
Impact. Adding a keyword silently breaks user code that used the new word as an identifier.
Plan. Add a checklist item to the release process: any new keyword must be tested against the existing fixture set and any third-party code that the team can scan.
Tier D: documentation drift
- MEP 9 must be regenerated whenever fixture counts change.
- Builtins listed in MEP 6 must match the actual code; today they do, but the list is hand-maintained.
- The error code table in MEP 6 is hand-maintained too. Consider a generator that emits Markdown from
types/errors.go.
Rationale
A weakness review that names files and lines is one we can act on. A vague list of "issues to fix someday" is one we cannot. We tier the items so the most consequential gaps get the most attention.
Backwards Compatibility
Each fix above is a breaking change for some programs. We pin current behaviour in fixtures so that the breakage is deliberate and traceable.
Reference Implementation
References to source files and lines are inline above.
Open Questions
- Path A or B for B1. Recommendation is Path A (rational division). Need consensus before shipping the fix.
- Soft versus hard
anyremoval. Whether to keepanyas an explicit opt-in cast target or eliminate it once polymorphism lands.
References
- See MEP 7 for the soundness contract this document offends against.
- See MEP 12 for the polymorphism upgrade that closes A1 and B5.
Copyright
This document is placed in the public domain.