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 (closed)
Status. Closed at the routing layer. Assignable in types/subtype.go no longer admits src.(AnyType) against a concrete dst; the only way for an any-typed value to flow into a non-any slot is an explicit _ as T cast (castOk allows any as _ per MEP-10 A5). The element-context carve-out preserves empty-collection literals ([] is [any], {} is {any: any}, null is option[any]) flowing into typed collection slots.
Pinning fixtures. tests/types/errors/any_top_silent_widen.mochi (previously the snapshot in valid/).
Original evidence. Assignable used to allow src.(AnyType) unconditionally, so let y: int = x where x: any type-checked silently. The unifier still admits any in both directions, but the unifier is consulted alongside Assignable at every assignment, argument, and return site, so the strict predicate is the gate.
Follow-up. Builtins still declared with AnyType{} returns (min, max, sum over user lists, doom.PI and other extern attributes) require user-side as T casts at consumption today. MEP-12.4 (#89) replaces those any-returns with TypeVar so the cast becomes unnecessary. MEP-10 B7 (#97) does the same for extern signatures.
A2. null is a value of any type (closed)
Status. Closed. checkPrimary in types/check.go types the null literal as OptionType{Elem: AnyType{}}. null unifies with any other OptionType{T} via the elem-level any short-circuit in unify, and with AnyType itself via the top-level any short-circuit. A non-option target like int, list<int>, or a struct name rejects null with T008.
Pinning fixtures. tests/types/valid/option_type_syntax.mochi for the accepted form (let maybe: int? = null). tests/types/errors/null_widening_int.mochi, tests/types/errors/null_widening_list.mochi, tests/types/errors/null_widening_struct.mochi for the rejected forms.
Original evidence. checkPrimary previously returned AnyType{} for null. let x: int = null type checked today and dereferencing x in arithmetic was a runtime error.
Follow-up. Explicit unwrap discipline (? accessor or match form) before use as the underlying type is tracked separately; today the option type is a marker that affects assignment compatibility, not a forced-unwrap site.
A3. Mutation through immutable bindings via aliasing (closed)
Status. Closed. The direct mutation paths (let xs = [1]; xs[0] = 2 and let p = Point{x: 1}; p.x = 2) were already rejected with T024. The remaining alias path is now rejected with T051: a var binding whose initialiser is a bare reference to a let-bound list, map, or struct is a checker error. The rule lives in the VarStmt walk in types/check.go and routes through the bareIdentName / isAliasableAggregate helpers there.
To opt in to sharing, declare the source as var. To break the alias explicitly, clone at the boundary ([...xs], {...m}, a struct literal copy, or a function call that returns a fresh aggregate).
Pinning fixtures. tests/types/errors/mutate_through_let_alias.mochi (the alias path, T051), tests/types/errors/mutate_through_let_index.mochi and tests/types/errors/mutate_through_let_field.mochi (the direct paths, T024).
Original evidence. tests/types/valid/mutate_through_let_alias.mochi used to pin the leak: the alias type-checked, and a write through the var mutated the let storage too.
Follow-up. The "explicit clone" syntaxes ([...xs], {...m}) are not yet a uniform language feature; today users rebuild the aggregate (for x in xs { ... }) or declare the source as var. The invariant-list-element half (B3) landed alongside this rule, so both halves of the aliasing story are closed at the checker.
A4. Match exhaustiveness not enforced (closed)
Status. Closed. checkMatchExpr in types/check.go collects the variant names matched by each arm and, after the arms loop, compares them against UnionType.Order. Any uncovered variant is reported with T050 unless a wildcard _ arm appears in the match. Variant arms (both bare V and call-form V(args)) and wildcard arms are recognised; literal and expression arms do not contribute to coverage and so still require either a full enumeration of variants or a _ arm.
Pinning fixtures. tests/types/valid/match_union_exhaustive_wildcard.mochi, tests/types/errors/match_missing_variant.mochi.
Original evidence. checkMatchExpr previously typed each arm but did not check that the union of arm patterns covered every variant of a UnionType scrutinee. Removing a variant from a union did not surface as a compile error in code that matched on it; users discovered the gap at runtime.
A5. as cast is unchecked (closed)
Status. Closed. castOk(from, to) at types/check.go is consulted from the cast site in checkPostfix. Allowed: identity, _ as any, any as _, numeric-tower casts, union-to-variant (when the variant is in the union), and map-literal to struct. Anything else is rejected with T046. Use int(...), str(...), parseIntStr(...) for parsing.
Pinning fixtures. tests/types/errors/cast_int_as_string.mochi, tests/types/errors/cast_string_as_int.mochi, tests/types/errors/missing_map_key_cast.mochi.
Original evidence. types/check.go:1886-1887 previously resolved the target type and returned it unchecked. (5 as string).len used to type check; the runtime then either crashed or silently coerced.
Tier B: surprising or incomplete
B1. Integer division mismatch between checker and runtime (closed)
Status. Closed. Path B (truncating). The checker types int / int as int (see types/infer.go) and the bytecode VM now emits OpDivInt for two integer operands at runtime/vm/vm.go:3232. The generic OpDiv runtime branch also truncates when both operands are integers (runtime/vm/vm.go:930). The interpreter (interpreter/runtime_utils.go:225-258) already truncated. Use a float operand (e.g. 5.0 / 2) when rational division is desired.
Pinning fixture. tests/types/valid/int_div_truncates.mochi asserts 5 / 2 == 2 and -7 / 2 == -3.
Original evidence. types/infer.go:116-118 typed int / int : int. The VM compiled int / int to OpDivFloat, so let x: int = 5 / 2; print(x) printed 2.5. The checker promised int and the program observed a float value flowing through.
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 (closed)
Status. Closed. At the var declaration site, when the initialiser is a bare identifier referring to an aliasable aggregate (list, map, struct), the destination type must equal the source type structurally rather than merely subtype it. The new binding shares storage with the source; a widened element type would let a later ys[i] = ... deposit a value the source's static type rejects, corrupting reads through the source name. Diagnostic is T052 at types/check.go (the var-binding branch under case s.Var != nil).
Pinning fixtures. tests/types/errors/list_alias_var_invariant.mochi (var ys: list<any> = xs where xs : list<int>) and tests/types/errors/list_alias_call_arg.mochi (fun corrupt(ys: list<any>); corrupt(xs) where xs : list<int>).
Function-arg boundary (B3b). The same invariance applies at call sites: passing xs : list<int> into a parameter declared list<any> is rejected with T052 because the parameter slot aliases the caller's storage and a param[i] = ... write would corrupt reads through the caller's name. The check is narrow: both arg and param must be aliasable aggregate kinds (list, map, struct), so any-typed interop parameters (json, str, to_json, ...) keep their by-design loose behaviour.
Index and field reads (B3c). Reads like rows[0] and obj.items return live storage that aliases the parent cell; var r: list<any> = rows[0] where rows : list<list<int>> and corrupt(rows[0]) where corrupt(ys: list<any>) are now rejected with T052 for the same reason. The check uses aliasSourceLabel so the diagnostic surfaces the read path (rows[...], obj.items) instead of just an identifier. Pinning fixture: tests/types/errors/list_alias_index.mochi.
Index and field assignment LHS (B3d). Symmetric to B3c on the write side: bag[0] = xs where bag : list<list<any>> and xs : list<int> is rejected. The slot type widens the source's element type, and a later bag[0][i] = ... would corrupt reads through xs. The same applies to obj.f = xs. Pinning fixture: tests/types/errors/list_alias_lhs_index.mochi.
Literal element positions (B3e). A list or map literal whose element expressions name live aggregate storage is rejected when the target slot widens the source's element type. var bag: list<list<any>> = [xs] and bag[0] = [xs] where xs : list<int> are caught at the inner position via checkLiteralAliasElements, which recurses into nested literals. Fresh-value elements (other literals, calls, computed values) keep working. Pinning fixture: tests/types/errors/list_alias_literal_elem.mochi.
Original evidence. types/check.go:172-189. unify(ListType{Int}, ListType{Any}) succeeded without restricting writes. Combined with the elementContext carve-out in assignableAt, a var ys: list<any> = xs aliased list<int> storage and let writes through ys corrupt xs.
B4. Pure flag enforcement narrow to query predicates
Evidence. The flag is set at builtin registration in types/check.go:488-720 and on user functions via isPureFunction (called from declaration sites at :759, :841, :1277, :1345). It is consumed in one pure position: where and having predicates, where an impure call is rejected with T044 ("impure call to %s is not allowed in %s predicate"). Pinned by tests/types/valid/pure_in_where_ok.mochi and tests/types/errors/impure_in_where_rejected.mochi.
Impact. The flag is honest for query predicates today but does not gate other positions where purity would matter (struct field defaults, type alias arguments, const-like let initialisers).
Plan. Extend the consumer set incrementally as the language gains pure positions. Each new position lands with a matching fixture pair.
B5. Generic functions parse but do not unify across calls (closed)
Status. Closed. Env carries a typeParams scope (SetTypeParam / LookupTypeParam in types/env.go), and resolveTypeRefInner consults it before the unknown-type diagnostic and the single-uppercase heuristic. Both the FunStmt prepass and the main FunStmt handler in types/check.go install a sigEnv with a fresh *TypeVar per declared TypeParam, then resolve params and return in that scope and set FuncType.TypeParams. At each call the primary call-site Instantiate freshens the quantified vars and the substitution-aware Unify constrains them across arguments. Conflicting unifications surface as T047; under-constrained results surface as T048.
Pinning fixture. tests/types/valid/user_generic_id.mochi exercises id<T>(x: T): T and pair<A, B>(a: A, b: B): A returning concrete types at distinct call sites without an as T cast.
Original evidence. TypeVar existed at types/check.go:186-200 and was used in unification but was not produced by inference of a function call's result. Each call site re-inferred from the declared signature, so id<T> parsed but was indistinguishable from a fixed any signature.
B6. Query select fallback to any (closed)
Status. Closed. The original concern was "select foo(bar) where foo returns any does not raise even when bar is mistyped". MEP-10 A1 (the any-tightening pass) routes the argument check at every call site through Assignable, so a mistyped bar argument now raises T007 regardless of foo's declared return type. MEP-12.4 then replaces the remaining any-returning builtins (first, reverse, push, append, concat, collect) with parametric TypeVar signatures, so the only any returns left in the projection are user-declared : any returns, which are an opt-in.
The non-aggregate select path in types/check.go types the projection as selT and wraps it in ListType{Elem: selT}. When selT is any (because the user wrote a function returning any), the query honestly types list<any>, and consumers must declare and cast at the boundary.
Pinning fixtures. tests/types/valid/query_basic_select.mochi, tests/types/valid/query_sort_take.mochi, tests/types/errors/query_source_not_list.mochi.
Original evidence. The aggregate dispatch in types/check.go explicitly types count, sum, avg, min, max and falls through to general expression typing for everything else. Before A1 + MEP-12.4, the fallthrough often produced any because builtins like append returned any and the argument-side check did not reject any-flowing-into-concrete. Both legs are now closed at the source.
B7. extern declarations are trust-the-author
Evidence. The ExternFun / ExternVar branches in types/check.go resolve declared types via resolveTypeRef and install the binding 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. Bounded by MEP-11.3 (any-to-T cast required) and MEP-10 A5 (cast compatibility checked), so the failure mode is "extern call returns wrong runtime value" rather than "any silently flows into a concrete slot."
Plan. Validate extern signatures against Go reflect-based introspection and Python inspect at extern-resolution time. Tracked as a follow-up initiative against #97 once the Go runtime exposes a stable foreign-symbol registry; for the v0.12 cycle this remains "trust the author" with the soundness bound above. Author discipline is the gate today.
B8. Recursive type definitions
Policy. Recursive types are permitted. The type-declaration walk in types/check.go (the union-build path under TypeDecl) installs the union into the env before its variants are resolved, so a variant field that names the enclosing union resolves without a forward-reference error. The classic Cons/Nil list and Tree (already pinned by tests/interpreter/valid/tree_sum.mochi and tests/compiler/valid/union_inorder.mochi) work today; the new fixture tests/types/valid/recursive_linked_list.mochi pins the linked-list shape at the type-check layer.
Evidence. types/check.go (union-build path under TypeDecl) and Env.SetUnion populate the variant map before fields are resolved, so a recursive variant field that names the enclosing union does not raise the unknown-type diagnostic.
Impact. Soundness is not at issue. The remaining concern is runtime: JSON serialisation of an aggregate that contains a cycle (only reachable today via var aliasing) walks forever.
Plan.
- Closed. Document the policy here and pin the linked-list fixture.
- Deferred. Add a runtime cycle check to
json()(andsave("jsonl")) so a cyclic aggregate raises a clear error rather than looping. Tracked under a follow-up task once the alias discipline from A3 lands; the cycle check is cheap to add but pointless to gate against until alias-induced cycles are observable at all.
B9. Shadowing without warning (deferred)
Evidence. types/env.go:205, 213 (SetVar and SetVarDeep) 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. Not a soundness issue: the rebound name has the rebound type, and both bindings type-check independently.
Decision. Deferred. A W001 warning tier sits outside the soundness initiative (MEP-4/5/7/10/11/12) and belongs in a future lint-MEP that introduces the warning surface, suppression directives, and tooling integration. Surfacing it earlier would mix lint policy with type-system invariants. Tracked at #99.
Tier C: parser and surface quirks
C1. nullable type shorthand (closed)
Status. Closed. The lexer now recognises ? as punctuation, TypeRef accepts a trailing ?, and resolveTypeRef desugars T? to OptionType{Elem: T}. The shorthand composes with every constructor: int?, list<int>?, Foo?, fun(int): bool ? all parse and resolve to option[...].
Pinning fixture. tests/types/valid/option_type_syntax.mochi.
Original evidence. parser/parser.go:219-225 previously did not list union as a constructor and there was no syntax for nullable types. The natural way to write a nullable type failed at parse.
Follow-up. Path "full int | nil union" is intentionally not taken: T? covers the nullable case and Mochi's named unions cover the general case. Wiring null literals into OptionType is tracked under A2.
C2. Unary minus on RHS of comparison without parens
Evidence. parser/parser.go:54-57 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:64 (Comment regex). /* ... */ 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:66 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
- 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.