AtlasLangimplementation

AtlasLang: Three Types and One Honest Limitation

`int`, `bool`, `str`; annotations optional on `let` and inferred from the initializer, required on parameters and returns. And a definite-return analysis so conservative it rejects `while (true) { return 1; }` — which is the cleanest example of soundness without completeness you will find.

The question

What can AtlasLang prove before running, and where does it give up on purpose?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

The AST annotated in place: a type on every expression node and a symbol on every identifier, produced by one pass that does name resolution and type checking together. It is the same tree the parser produced, with two fields filled in — nothing is restructured, which is why the AST panel and the Typed AST panel in the pipeline explorer have identical shape. The tree exists in this form to answer one question for every later phase: is this program well-formed, and may everything downstream assume so?

What this phase may assume or do

The checker may assume from the parser that the tree is structurally valid and that every node carries a span; it owes every later phase the guarantee that a program which reaches them type-checked. That guarantee is what licenses the whole back end — the lowering may assume + has two int operands because a program where it does not never got here. Internally, the rule that makes the pass usable is that an error type is absorbing: any expression with an error operand yields error and reports nothing further, so one mistake produces one diagnostic rather than eleven.

Key points

  • int, bool, str, and nothing else — which is what keeps every typing rule local and readable.
  • Annotations are optional on let and required on parameters and returns, so a function signature is checkable without looking at any call site.
  • Type checking and name resolution are one pass, because a name must be declared before use — the scope stack and the type environment are the same structure.
  • The definite-return analysis is three syntactic rules and rejects while (true) { return 1; } — sound, incomplete, and deliberately so.
  • Sound means it never accepts a function that can fall off the end; incomplete means it rejects some that are fine. Every useful analysis chooses which way to err.
  • An absorbing error type is what stops one mistake from producing a diagnostic per enclosing node.
  • The checker's guarantee is what licenses every later phase to skip its own checks, which is what a type system is actually for.
  • It proves what it models: 10 / 0 type-checks and faults at run time, because divisor non-zeroness was never part of the model.

Three types, and where an annotation is required

simplifiedThree ground types with no polymorphism means AtlasLang's inference is a bottom-up computation, not the constraint-solving that Hindley-Milner performs. A language with type variables must generate constraints and unify them, which is a different algorithm with different failure modes — most notably error messages that point at the wrong expression, because unification fails wherever the constraints happen to meet rather than where the mistake was. See [[hindley-milner]] and [[unification]].

int, bool, str. That is the whole type system: no floats, no arrays, no user-defined types, no generics, no null. Each absence removes a genuinely hard problem, and the resulting system is small enough that every typing rule fits on a screen.

Annotations are optional on `let` and required on parameters and return types. That split is the most common design in modern statically typed languages, and it is not arbitrary. A let has an initializer sitting right there, so the type is locally derivable and writing it adds nothing. A parameter has no initializer, and inferring it would require looking at every call site — which turns a local check into a whole-program analysis and makes an error at one call site report at the function definition. Requiring the annotation makes a function signature a contract that can be checked independently of its callers, which is what makes separate compilation and good error locality possible at all.

Where a let does carry an annotation, it is checked against the initializer rather than ignored: let b: bool = 5; reports "b is declared bool but its initializer has type int", with the help "Change the annotation or the initializer so they agree." The symbol records whether its type was written or inferred, which is what lets a hover or a dump distinguish the two.

The operator rules are a table plus two special cases. Arithmetic — + - * / % — takes two int and yields int. Comparison — < <= > >= — takes two int and yields bool. Logical — && || — takes two bool and yields bool. The first special case is equality, which is defined for any two operands of the *same* type and is the one polymorphic operator here. The second is + on two str, which is concatenation — the same symbol meaning two different operations, which is exactly the ad-hoc polymorphism a checker has to resolve rather than an ambiguity it can push downstream.

Where a type comes fromimplementation
PositionAnnotationIf absentWhy
let bindingOptionalInferred from the initializerThe initializer is right there; the inference is local and always succeeds or errors locally.
Function parameterRequiredParse error: "Expected a type for a parameter"Inferring it would need every call site, turning a local check into a whole-program one.
Return typeRequiredParse error: "Expected a type as the return type"It is half the contract a caller checks against, and it makes the definite-return analysis possible.
ExpressionNever writtenComputed bottom-up from the operandsEvery rule is local: the type of a node is a function of its children's types.

The definite-return analysis, and why it is wrong on purpose

A function declared to return int must return an int on every path. Checking that requires deciding whether a statement list definitely returns, and AtlasLang's alwaysReturns does it on the tree, before any CFG exists.

It is three rules. A return statement definitely returns. A block definitely returns if its statements do. An if definitely returns if it has an else and both arms do. That is all. A while never counts, because the checker does not evaluate the condition and therefore cannot know the body runs even once.

The consequence is that fn f(): int { while (true) { return 1; } } is rejected: "Not every path through f returns a value." Every human reading it knows the loop is entered and the function returns 1. The analysis does not, because to know it, it would have to evaluate the condition — and once an analysis starts evaluating conditions to decide reachability, it is asking a question that is undecidable in general.

This is the textbook shape of a sound but incomplete static analysis, and it is worth being precise about the two words. *Sound*: it never accepts a function that can fall off the end without returning — no false negatives, which is the direction that would produce a miscompilation. *Incomplete*: it rejects some functions that are actually fine — false positives, which are an inconvenience. Every useful static analysis makes exactly this trade, and which direction it errs in is the design decision. Erring towards rejection means a user occasionally has to restructure correct code; erring towards acceptance means the guarantee is not a guarantee.

Real languages make the same choice and produce the same complaint. Java rejects a method that ends in a while (true) without a return unless the condition is a compile-time constant, and the special case for constants is precisely an admission that the general question is not answerable. Rust, C# and Go all have their own version of this line, drawn in slightly different places, and every one of them generates the same bug report from users every year.

Two functions, one accepted and one not
Before
fn f(): int {
  while (true) { return 1; }
}
// REJECTED: "Not every path through `f` returns a value."
// help: "`f` is declared to return `int`."
After
fn g(n: int): int {
  if (n > 0) { return 1; } else { return 2; }
}
// ACCEPTED: an `if` with an `else` where both arms return
Legal only when

The checker may accept a function only when it can establish, without evaluating any condition, that every path reaches a return. The three rules — a return returns, a block returns if its statements do, an if returns if it has an else and both arms return — are exactly the cases where that holds syntactically.

Illegal when

The analysis tries to be complete by reasoning about which conditions are true. Deciding whether an arbitrary loop is entered, or whether a branch is reachable, is undecidable in general; an analysis that guesses is no longer sound, and unsoundness here means accepting a function that can fall off its end and return whatever happens to be in the return register.

Errors that do not cascade

The checker reports and keeps going rather than throwing. That requires a mechanism, because the obvious implementation produces a flood: a variable that does not exist makes its enclosing expression untypeable, which makes *its* enclosing expression untypeable, and one mistake generates a message per enclosing node.

The mechanism is an absorbing error type. When a check fails, the node is given type error and a diagnostic is reported. Every rule that sees an error operand yields error and reports nothing. So print(nope + 1 * 2); where nope is undefined produces exactly one diagnostic — "Cannot find nope in this scope" — rather than one for the lookup, one for the addition and one for the print.

The same pass also offers suggestions, with a budget. On an unresolved name it computes edit distance against every visible name and suggests the closest — but only if the distance is within one edit for short names, or a third of the length for longer ones. Beyond that it says nothing, because a confidently wrong suggestion that happens to compile is worse than no suggestion at all. That budget is the whole of [[suggested-fixes]] in one number.

A third detail worth noticing: an if or while condition must be bool, and the diagnostic says so with the help "AtlasLang has no truthiness; write an explicit comparison." The language chose not to have truthiness, and the error message is where that choice is explained to the person who did not know it. Diagnostics are where language design decisions get communicated, and a message that only says "type error" wastes the opportunity.

  • let b: bool = 5; — "b is declared bool but its initializer has type int."
  • let q = 1 + true; — "+ is not defined for int and bool", with the help "+ expects two int operands."
  • if (1) { ... } — "An if condition must be bool, found int", with the help "AtlasLang has no truthiness; write an explicit comparison."
  • print(nope); — "Cannot find nope in this scope", plus a "Did you mean ...?" if a visible name is within the edit budget.
  • f(1) where f takes two — "f takes 2 arguments, but 1 was supplied", with per-argument type errors reported separately.
  • let x = g(); where g returns nothing — "Cannot bind x to a value of type void", with the help naming the cause.

What the guarantee buys downstream

simplifiedAtlasLang integers are JavaScript doubles truncated toward zero. They do not wrap at 32 or 64 bits, so the language cannot demonstrate signed overflow — which is where a real compiler's undefined-behavior reasoning lives, and the single most consequential difference between this type system and C's. A C compiler may assume signed overflow does not happen and optimize on that assumption; AtlasLang has no such licence because it has no such rule.

The reason to care about any of this is what the rest of the compiler is then allowed to assume. Lowering never checks whether + has integer operands; the checker guaranteed it. The optimizer never checks whether a branch condition is a boolean; the checker guaranteed it. The VM never checks whether a MUL has two numbers on the stack. Every one of those checks would otherwise have to exist somewhere, at some cost, or be an unchecked assumption that a malformed program could violate.

This is the concrete form of a claim that is easy to state abstractly: a type system is not primarily a service to the programmer, it is a contract between compiler phases. The programmer-facing benefit — catching mistakes early — is real, and it is the same guarantee viewed from the other side.

It is also worth being clear about the limit, because it is the most misstated claim in this whole domain. A static type system proves the properties it models and nothing else. AtlasLang's proves that operators receive operands of the types they are defined for. It does not prove that a program terminates, that a division has a non-zero divisor, or that an integer stays in range — and let a = 10; let b = 0; print(a / b); type-checks perfectly and faults at run time. The type system was never asked about it.

How it works

The steps, in the order the compiler takes them.

  • One pass walks the tree, maintaining a stack of scopes that serves as both the symbol table and the type environment.
  • Function declarations are collected before any body is checked, so a call to a function declared later in the file resolves.
  • Expressions are typed bottom-up: each node's type is computed from its children's types via a rule table, with equality and string concatenation as the two special cases.
  • A let without an annotation takes its type from the initializer and records that the type was inferred; with an annotation, the two are compared and the annotation wins on mismatch.
  • A failing check assigns the node type error and reports once; any rule seeing an error operand yields error and reports nothing further.
  • An unresolved name triggers an edit-distance search over visible names, suggesting the closest only if it is within a length-scaled budget.
  • After a function body is checked, alwaysReturns runs over its statement list: a return counts, a block counts if its statements do, an if counts only with an else and both arms counting, and a while never counts.
  • Types and symbols are written onto the existing nodes, so the tree is annotated rather than rebuilt.

How it breaks

What the engineer observes when it goes wrong — not what goes wrong internally.

  • A correct function with while (true) { return 1; } is rejected, and the author has to restructure working code to satisfy an analysis that cannot evaluate the condition.
  • Without the absorbing error type, one undefined variable produces a diagnostic for every enclosing expression, and the real message is buried.
  • An over-generous suggestion budget produces "Did you mean y?" for an unrelated name, and a user takes the advice, and the program compiles and does the wrong thing.
  • Inferring parameter types from call sites would report an error at the function definition for a mistake made at one of its callers, which is the worst possible error locality.
  • A checker that throws on the first error reports one problem per compile, so a file with four type errors takes four compiles to fix.
  • Believing the type system proves more than it models: 10 / 0 passes every check and faults at run time, and no amount of annotation would have caught it.

When it helps

  • Understanding why mainstream languages require parameter annotations while inferring locals — the reason is error locality and separate checkability, not implementation difficulty.
  • Recognising sound-but-incomplete analyses in the wild, which is most of them: definite assignment, exhaustiveness, borrow checking and nullability all err in the same direction for the same reason.
  • Designing diagnostics, where the absorbing error type and the suggestion budget are two small mechanisms that improve output disproportionately.
  • Arguing about what a type system does and does not guarantee, with a concrete example — a division by zero that type-checks — rather than in the abstract.

When it hurts

  • As a model of type inference in general. AtlasLang infers bottom-up because it has no polymorphism; a language with type variables needs constraint generation and unification, with entirely different error behaviour.
  • For anything involving subtyping, variance, generics or nullability, none of which exist here — and each of which is where real type systems get hard.
  • As reassurance. Three ground types with no overflow, no null and no aliasing prove very little, and the temptation is to generalise the confidence rather than the reasoning.

What it costs

Every one of these is paid by something.

  • A conservative definite-return analysis buys soundness with three syntactic rules and pays by rejecting correct programs, which users experience as the compiler being wrong.
  • Requiring parameter and return annotations buys per-function checkability and good error locality and pays in keystrokes on every function.
  • Inferring let types buys concise code and pays with a type that is not written anywhere, so a reader has to derive it or ask the tooling.
  • The absorbing error type buys one diagnostic per mistake and pays by suppressing genuinely independent errors that happen to sit inside a failed expression.
  • A capped suggestion budget buys trustworthy "did you mean" advice and pays by staying silent in cases where a human would have guessed correctly.
  • A small type system buys a readable checker and pays by proving very little — nothing about ranges, termination, division or aliasing.

What else you could do

What a different compiler or language does instead, and when that is better.

  • Hindley-Milner inference would let parameters go unannotated at the cost of constraint solving and error messages that point where unification failed rather than where the mistake was — [[hindley-milner]].
  • Dynamic typing removes the phase entirely and moves every one of these errors to run time, which is a real design choice with real benefits — [[static-vs-dynamic-typing]].
  • Gradual typing allows annotations to be partial and inserts run-time checks at the boundary, which is TypeScript's and Python's answer — [[gradual-typing]].
  • A flow-sensitive definite-return analysis on the CFG rather than the tree would accept while (true) { return 1; } by noticing the loop has no exit edge. It is strictly more precise, needs a CFG the checker does not have yet, and is still incomplete — just less often.
  • Refinement or dependent types could prove the divisor non-zero and reject 10 / 0 at compile time, at a cost in annotation burden and checker complexity that few general-purpose languages have accepted.

See it for yourself

The flag, dump or tool that shows you this directly.

  • /compilers/pipeline — the Typed AST panel shows the same tree as the AST panel with type and symbol filled in, which is what "annotates rather than restructures" means.
  • Type fn f(): int { while (true) { return 1; } } on the playground and read the rejection, then compare with the if/else version that passes.
  • Type let b: bool = 5;, let q = 1 + true; and if (1) { print(0); } and read the three help lines — each explains a language rule rather than restating the error.
  • Type print(nope); after declaring note and see whether the suggestion fires; then try a name far enough away that it does not.
  • Load the trap example: it type-checks completely and faults at run time, which is the type system's limit made concrete.
  • src/compilers/sim/check.tsBINARY_RULES is three rules and alwaysReturns is three cases.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "The compiler could easily see that while (true) always returns." It could, with a special case for literal conditions, which is what Java does. The general question — which loops are entered — is undecidable, so any rule is a line drawn somewhere.
  • "Rejecting correct programs is a bug." It is the chosen direction of incompleteness. The alternative direction accepts functions that fall off their end, which is a miscompilation rather than an inconvenience.
  • "Type inference means you never write types." It means you do not write the ones that are locally derivable. Parameters and returns are required here for the same reason they are required in Rust, Go and C#.
  • "It type-checks, so it will not fail at run time." It will not fail *in the ways the type system models*. Division by zero is not one of them, and no annotation in this language would make it one.

Misconceptions

The claim, and what is actually true.

A better compiler would accept while (true) { return 1; }.
A compiler with a different rule would. Accepting it in general requires deciding which loops are entered, which is undecidable, so every language draws an arbitrary line and this one drew it simply.
Type inference and dynamic typing are similar.
Inference computes a static type you did not write. Dynamic typing has no static type at all. The first rejects 1 + true at compile time; the second discovers it at run time or not at all.
Requiring parameter types is a limitation of a simple compiler.
It is a deliberate choice made by Rust, Go, C#, Kotlin and Swift, all of which could infer more. It buys error locality and per-function checkability, which are worth more than the keystrokes.
The error type is a hack to avoid crashing.
It is the mechanism that makes multi-error reporting readable. Without an absorbing error type, one mistake reports once per enclosing expression.

Go deeper

The same idea at increasing depth. Stop wherever it stops being useful.

overview

AtlasLang has three types. You can leave the type off a let because the initializer tells the compiler what it is; you must write it on function parameters and return types, because nothing local would tell it otherwise. The checker also insists that every path through a function returns a value, and it decides that by a simple syntactic rule — which means it rejects while (true) { return 1; } even though that function obviously works.

practical

Two habits transfer from this checker to any you write. Make errors absorbing: give a failed node an error type and report nothing further about anything containing it, or one mistake becomes a page. And put the language rule in the help line, not just the error — "AtlasLang has no truthiness; write an explicit comparison" teaches something that "type error: expected bool" does not. When your analysis rejects something correct, be explicit that you chose that direction; users forgive a documented conservatism far more readily than an apparently arbitrary refusal.

advanced

The definite-return rule is worth generalising because it is the same shape as every other analysis you will meet. Any interesting property of program behaviour is undecidable in general, so a checker approximates, and an approximation errs in one of two directions. Erring towards rejection keeps the guarantee and costs users some correct programs — definite assignment, exhaustiveness checking, borrow checking and nullability all sit here. Erring towards acceptance keeps every correct program and gives up the guarantee, which is what a linter is. The choice is not about analysis quality; it is about whether downstream phases are allowed to *rely* on the result. AtlasLang's lowering relies on it, so the analysis must be sound, so it must reject some correct programs. Improving it — by running on the CFG and noticing a loop with no exit edge, for instance — moves the line without removing it, and there is always another program on the far side.

How much this depends on

Nothing in this domain is true of every compiler. These say how much.

implementationThe three types, the operator table, the annotation policy and the three-rule definite-return analysis are AtlasLang's, in src/compilers/sim/check.ts at this revision. Every real language draws these lines differently: Java special-cases constant loop conditions, Go requires a terminating statement by a syntactic rule of its own, Rust uses the CFG. The shape of the trade transfers; the rules do not.
simplifiedNo floats, no arrays, no user-defined types, no generics, no null, no subtyping, no overflow. Each absence removes a hard problem — floating-point equality and identity, aliasing, variance, exhaustiveness, and undefined-behavior reasoning about signed overflow. This checker is small because the language is, not because type checking is easy.
specWhere a language draws the definite-return line is a specification decision, not an implementation one. Java's rule turns on whether the condition is a constant expression; Go requires the body to end in a terminating statement as syntactically defined; C does not require it at all and falling off the end of a value-returning function is undefined behavior. The same program is legal, illegal or undefined depending on which specification you read it under.

If you were asked this in an interview

  • Why are parameter types required when let types are not?
  • AtlasLang rejects while (true) { return 1; }. Is that a bug? Defend your answer in terms of soundness and completeness.
  • What does the checker's guarantee let the lowering and the VM skip doing?

Connections

Domains that do not exist yet
  • Programming Languages & Runtime Internals — What types become at run time — erased, reified, or a tag on every value
    AtlasLang erases entirely: the VM sees numbers and strings with no type information attached, because the checker already proved the operations valid. What a runtime keeps instead, and what it costs to keep it, is that domain's subject.