23 · Caught earlier

Why does Glyph catch what tsc catches, but earlier?

Because an error against generated TypeScript is an error about code you didn't write. Glyph reports these against your source, with the rule you broke and the edit that fixes it.

Why it matters

Glyph compiles to TypeScript and then type-checks the output with tsc --strict, so a mistake Glyph doesn't model isn't lost. It comes back one stage later, phrased in terms of the emitted file: a TS1308 pointing at an await in a function you never spelled, or a TS2366 about a switch you didn't write. The line is mapped back to your .glyph, but the explanation isn't.

That gap is where the compiler stops teaching. So each rule Glyph can decide for itself moves up: await outside an async fn (E0222), a match arm that produces no value while the match is used as a value (E0223), and an assignment written without mut (E0008). Each names the rule, cites the decision behind it, and shows the before and after.

See it

The arm case is the one that used to escape entirely. {} in arm position is an empty block, not an empty record, so the arm ran and produced nothing:

status.glyph: an arm that yields nothing, in a position that needs a value
fn label(s: Status) -> string {
  let text = match s {
    Loading => {},          // an empty block: the arm produces no value
    Ready => "done",
    Failed(msg) => msg,
  }
  return text
}
glyph build
[E0223] this `match` arm produces no value, but the `match` is used as a value
   ╰─ End the arm with an expression, or `return` from it. `X => {}` is a no-op
      only where the `match` is a statement.
   note: A value-position arm lowers to `case X: { break; }` when it yields
         nothing, so the value would be `undefined` at run time.

Before this, the emitted switch fell out of its own cases and text held undefined while its type said string. tsc catches some shapes of that and misses others. Glyph now decides it wherever the position is decidable: a let, a mut, a return, or the tail of a function that declares a return type. A statement match keeps X => {} as the deliberate no-op it is.

await is the same move. Glyph has no user-visible Promise: an async fn -> T is awaited to a T, so await anywhere else has nothing to suspend. That used to be tsc's TS1308 on the generated file. It is E0222 now, and the innermost enclosing callable decides, so a synchronous lambda inside an async fn is its own context, the same rule TypeScript uses.

glyph build, on `fn total() -> number { return await fetch_count() }`
[E0222] `await` is only valid inside an `async fn`
   ╰─ Mark the enclosing function `async fn`, or call a non-async function here.

// before: no Glyph diagnostic, then
dist/main.ts:14:18 - error TS1308: 'await' expressions are only allowed
within async functions and at the top levels of modules.

The third one is about a rule people break on day one. Glyph marks every mutation, so reassigning a binding is mut x = e and introducing one is let x = e. A bare x = e used to report "unexpected token: Equals", which names a character rather than the rule. It is E0008 now, and glyph --explain E0008 spells out why the mark exists: every place a value changes starts with mut, so grep "mut total" finds all of them.

The ? operator gets the same treatment from the other direction. ? expands to a binding plus an early return placed before the statement it sits in, so it needs a statement to expand into. It works in a let, a return, an argument, and now in a match arm body. Where it can't work, the message says which position it is and how to bind out of it (E0302 and E0303) instead of claiming the operator isn't implemented.

The worse case: when tsc is happy too

A tsc error in the wrong place is annoying. A clean build on a program that means something else is the real problem, and Glyph had one. Every top-level name you declare goes into the emitted module verbatim, including a tagged union's variant constructors, so a variant named Error emits export function Error(...) at module top level. The new Error(...) the compiler writes below it then calls your variant.

type Key = string | number is the same defect with a friendlier face. In Glyph A | B declares a tagged union whose members are variant constructors, so that line declares variants called string and number. It compiled, passed tsc --strict, and emitted an export const number that shadowed the prelude number namespace. Nothing failed until a later number.to_string call did.

glyph build, on a union with an `Error` variant
[E0110] `Error` cannot name a type, variant, or function: the emitted module
        references the JavaScript global `Error`, and this declaration would
        shadow it
 5 │   | Error(string)
   │     ──────┬─────
   ╰─ Rename it (e.g. `Error` -> `Failure`). The name is legal TypeScript, so
      nothing downstream catches the rebinding.

// before: no Glyph diagnostic, then, twenty lines later
dist/main.ts:31:12 - error TS2345: Argument of type 'string' is not
assignable to parameter of type ...

The span is the declaration, which is where the rename goes. The guarded list is derived from the emitter rather than from a general list of JavaScript globals, and a test greps the emitter and fails when a new global reference appears without a matching entry. That test is the point: Number was harmless until int shipped and started emitting Number.isInteger, which is how this reached a release in the first place. Date, Math, and JSON stay free, because nothing Glyph emits mentions them.

The primitive-union case gets its own code, E0111, because rejecting it without explaining it teaches nothing. It says what the line actually parsed as, points at named variants, and names extern_ts("string | number") for the boundary where you want the raw TypeScript union. Every name Glyph will not let you use is tabulated in docs/reference/reserved-words.md: the 32 keywords, the 33 TypeScript reserved words behind E0109, and the shadow list, each with the reason the name is taken.

Worth being clear about what this does not do. You still cannot name a type or a variant Error, and the spreadsheet that turned this up ships a variant called Cellerr because of it. The program it wanted to be is still not writable; what changed is that the compiler now says so at the declaration instead of letting the build go green and mean something else. Making the name usable means mangling Glyph names on the way into TypeScript, which changes what a stack trace, a grep over dist/, and a hand-written extern/ shim see, so it is not a patch we can make quietly.

A wrong message is worse than no message

The shadow list started with names the emitter writes as values. It was missing the ones it writes as types, and that produced the sharpest version of this whole problem. Every descriptor's parse returns Result<T, Issue[]>, so the emitted module writes Issue whether or not you did. Declare type Issue = { path: Array<string>, message: string } in your own module and yours wins, the emitted Issue objects carry a code field yours has never heard of, and the build fails with a tsc error about generated code, at a span pointing somewhere else.

glyph build, before: fifteen of these, none of them near the cause
dist/corpus/infer_output.ts:52:9 - error TS2353: Object literal may only
specify known properties, and 'code' does not exist in type 'Issue'.
glyph build, now
[E0110] `Issue` cannot name a type, variant, or function: the emitted module
        references the Glyph prelude global `Issue`, and this declaration
        would shadow it
   ╰─ Rename it. The name is legal TypeScript, so nothing downstream catches
      the rebinding.

Record joined the list at the same time, filed with the JavaScript globals rather than the prelude names because it is a TypeScript built-in and claiming otherwise would make the message wrong about where the name comes from. The drift test that greps the emitter now covers ambient types too, and it had to learn to see Issue[] in a type position, which is the reason this reached a release. Schema, Component and Option stay legal on the same rule that keeps Date free: the emitter writes them only because you wrote them in an annotation, so declaring one shadows nothing.

The other one in this class was an import. A local import path resolves from the build root, the directory you hand to glyph build, not from the directory of the file doing the importing. Build an enclosing tree and import catalog finds nothing, and until now nothing said so. The type degraded to unknown, a match over it lost the variants it was matching, and what you got was E0218, non-exhaustive match, on a match that covered every case. Doing what that message asked would have added a dead else arm to correct code.

glyph build examples/apps, on `import catalog` inside csvql/
[E0104] unresolved import `catalog`: no module `catalog` under the build
        root `examples/apps`. There is a `csvql/catalog.glyph` under the
        root; a local import path is resolved from the build root, not from
        the importing file's directory (D15)
   ╰─ Build that module's own directory as the root, or spell the import path
      as it reads from the root.

// before: no import diagnostic, then
[E0218] non-exhaustive match on string   // on a match over all four values

The risk in a check like this is that it fires on things that are fine, and an import checker that cries wolf on every npm package is worse than the silence it replaced. So the build first collects the module names it can resolve without a .glyph file: every declare module "X" under <root>/.types/ and in the bundled Node shim, plus every package in the project's node_modules. A name on that list is never reported, even when a local file happens to share its basename. When a project has no node_modules at all, the compiler cannot tell an uninstalled dependency from a typo, so it reports only what it can prove: an import that some .glyph file under the root answers to.

Resolution itself did not change. The same programs build and the same programs fail. What changed is that the failure now names the import instead of accusing a match two files away, which is the whole of it: a compiler that reports the wrong cause costs you the hour spent chasing it, and then the benefit of the doubt on every message after.

Naming the rule, not the token

The quieter version of the same problem is an error that is correct and still tells you nothing. An agent writing a red-black tree reached for the positional constructor every ML-family language has, and the parser stopped on its first comma with “expected )” and the advice “add the expected token”. Adding a ) there produces a different program, not a working one. A Glyph variant carries one payload, and a payload with several fields is a record. Neither of those sentences was anywhere in the output.

glyph check, before and now
[E0002] parse: expected `)` after variant payload, found Comma
   ╰─ Add the expected token.

[E0010] a union variant carries one payload, but `Node` lists 6 positional
        fields
   ╰─ Glyph has no tuple payload. Put the fields in one record and name them:
      `Node({ /* name */: Color, /* name */: Tree<K, V>, /* name */: K,
              /* name */: V, /* name */: int, /* name */: Tree<K, V> })`,
      and destructure it by those names in a match arm.

To say that, the parser had to read the whole list before refusing it, which is also what lets it count. The span underlines all six fields rather than the separator it happened to die on, and the suggested record is built from the variant name and the six types in the file, so the fix names your program rather than an example of someone else’s. Field names are the one thing the compiler cannot supply, so it leaves them as holes.

The rule behind the message is a choice, not a gap. Node(Black, l, x, b, h, r) is the argument swap that type-checks, and position four tells a reader nothing. The record spelling names every field, a match arm names only the fields it uses, and both of those are things grep can find.

Two more the checker took over in 0.1.121

Order { id: "a", total: 1 } is what someone coming from TypeScript writes to build a record, and Glyph has no such form: a value is written on its own and the type goes on the annotation. What the compiler used to say about that line was E0108, unreachable code, on a build that exited 0, because the name resolved to the type and the braces parsed as a block after the return. It is E0228 now, and the message is read off the declaration, so a record lists its fields, a tagged union lists its variants with their payload shapes, and a string-literal union lists its literals. Order.parse(raw) and Order.is(v) stay legal, since the receiver of a type's own descriptor is the one expression position a type name belongs in.

glyph check, on a record built the TypeScript way
[E0228] `Order` is a type, not a value; a value is the record literal
        `{ id: ..., total: ... }`, with the type on the annotation
   ╰─ Write the value in its own form and move the type’s name to the
      annotation on the `let`, the parameter, or the return type.

// before: [E0108] Warning: lint: unreachable code, and exit 0

The other one is a prelude container assigned to a primitive. let x: string = o where o: Option<int> drew nothing from the Glyph checker, and neither did passing a Nullable<int> to a parameter declared int. Five containers are covered in both directions now: Option, Result, Array, Record and Nullable are never a string, a number or a bool, whatever their arguments, and Nullable<T> against a scalar is decided one level in against T, so let v: Nullable<int> = 3 stays correct and let v: Nullable<int> = "three" is E0204. The Nullable case belongs on this page more than the others, because it is one tsc cannot make: the emitted const v: number | null = 3 narrows back to number before the call is checked, so a boundary type that exists to make null visible was passing silently where an int was declared, and the Glyph checker was the only place it could have been caught.

And two more in 0.1.122

type Mode = "read" | "write" declares a finite set of strings, and the Glyph checker compared a value against it by nothing at all. A number, a bool, a record and a string outside the set all passed glyph check --no-tsc with nothing but unused-variable warnings, while tsc refused every one of them on the emitted TypeScript. So a full glyph build was never wrong about this, but the four surfaces that have no tsc in the loop were: --no-tsc, the language server, the MCP tools and the playground.

glyph check --no-tsc, on a type that lists its legal strings
type Mode = "read" | "write"

let a: Mode = 3
let b: Mode = "rw"
let c: Mode = takes_mode(true)
let d: Mode = { x: 1 }
// before: four unused-variable warnings, exit 0
[E0204] type mismatch: expected `Mode`, found `number`
[E0204] type mismatch: expected `Mode`, found `"rw"`
[E0211] argument type mismatch: expected `Mode`, found `bool`
[E0204] type mismatch: expected `Mode`, found `record`

That is the four tsc reported, at the same four statements, without running tsc. The rule reads the union as what it is at run time, a string, and asks the checker the corresponding string question in each direction, so it inherits the rules that already refuse a record or a container against a string rather than restating them one shape at a time. Two literal sets are compared by set, so a narrower union goes where a wider one is declared; an alias chain is followed on both sides; an imported union is decided like a local one; and Nullable<Mode> is descended with the union intact.

A union nested in a record field or a generic argument was left to tsc in 0.1.122 and is decided in 0.1.123. See below.

The second one is not a tsc story at all. The parser is recursive descent with no depth guard, so an expression nested two thousand levels deep ran the process out of stack and aborted with exit 134. That is bad on glyph check and worse on glyph lsp and glyph mcp, which read every file under the root: one pathological file took the server down for the whole workspace. It is E0011 now, raised at the token that would have opened level 65.

0.1.123: the literal a value is, not the string it emits to

type Mode = "read" | "write" put the set on the declaration and nothing on the value, so "read" was a string and ["read", "write"] an Array<string>. That made two programs indistinguishable: return ["read", "write"] where an Array<Mode> is declared, which tsc compiles, and return xs for an xs: Array<string>, which it refuses. 0.1.122 declined the pairing rather than get it wrong, and that bought the right program by giving up the wrong one: { mode: "nope" } against a { mode: Mode } drew nothing.

A written string literal now has the one-literal type it spells, an array literal's element type is the join of its elements', and a written array or object literal is checked element by element and field by field against the declared type, so the diagnostic names the literal and underlines it.

glyph check --no-tsc, on a literal one container in
type Mode = "read" | "write"
type Cfg = { mode: Mode }

fn ok() -> Array<Mode> { return ["read", "write"] }
fn bad() -> Array<Mode> { return ["read", "nope"] }
fn wrong() -> Cfg { return { mode: "nope" } }
// 0.1.122: nothing, on all three
[E0204] type mismatch: expected `Mode`, found `"nope"`   (twice)

The literal widens back to string where TypeScript widens it: at a let with no annotation, whose emitted TypeScript let widens the same way, and at the element binding of a for over a written array. So let l = "read" followed by takes_mode(l) is refused here and TS2345 there. A const emits a TypeScript const and keeps its literal, so takes_mode(CM) compiles under both. A literal type that arrived through a name is not widened, which is TypeScript's own freshness rule. Every row of that was settled by running tsc --strict on the equivalent TypeScript rather than reasoned about.

One pairing is still declined: the key argument of a Record against another Record's. TypeScript writes Record<string, V> as an index signature covering every key a Record<Mode, V> declares and accepts it in both directions, so refusing it would refuse a program that compiles. Two more shapes the checker still says nothing about: a literal inside Some(...), because a prelude constructor has no type here yet, and a record literal bound to an unannotated let, because no record type is synthesized for an object literal.

A module under std/ was dead code with no diagnostic

std/ and extern/ name modules the compiler carries, and the resolver reserved them for imports. Nothing stopped a project file from declaring one. module std/io in your own src/io.glyph compiled clean, and then import std/io { shout } resolved to the stub and reported that shout is not exported: your file was never read. A file that draws no diagnostic and runs no code is the worst shape a compiler can leave you in, because nothing about it looks wrong.

glyph check --no-tsc, on src/io.glyph declaring `module std/io`
// before: glyph check: 2 module(s) checked, no diagnostics.  (exit 0)

[E0113] `std/io` declares a module under `std/`, a prefix reserved for the
        modules the compiler carries (D15)
 1 │ module std/io
   │ ──────┬──────
   ╰─ Rename the module to a path of your own (`io`, `app/io`).

The prefix is matched as a whole first segment, so standard, externals/io, app/std and app/extern/helper are untouched, and importing under either prefix still works the way it always did.

glyph check, on `const x = ` followed by 2,000 nested brackets
// before: thread 'main' has overflowed its stack
//         fatal runtime error: stack overflow, aborting   (exit 134)

[E0011] this array literal nests deeper than the parser's limit of 64 levels
   ╰─ Name the inner levels: pull them out into `let` bindings, or into a
      `fn` that returns one of them, so no single expression, type or
      pattern is more than 64 levels deep.

The limit is 64 and it was measured rather than picked. One level of the costliest construct, a nested array literal, costs 7,792 bytes of stack in the release build and 8,810 in the debug one, found by binary-searching the smallest thread stack a given depth will parse in. The thinnest stack the parser runs on is a spawned thread's 2 MiB, which is what the language server's workers get, and 64 levels spend under a third of it in the costlier build. The deepest nesting across the 343 .glyph files in this repository is 16. The limit covers expressions, types, patterns, blocks and JSX children alike, and a thousand sibling arrays still parse, because depth is per-construct nesting and not a budget spent over the file.

When neither compiler is wrong

There is a third shape, and it is the one that reads worst. Glyph reports nothing, tsc reports something true, and the program you wrote was fine. What failed is the translation between them.

A binding in Glyph has one type for the whole of its life. TypeScript narrows a binding to whatever it last saw assigned, and its boolean is the union true | false, so let done = false reached tsc typed false and the true arm of the emitted switch had nothing to match.

poll.glyph, and what tsc said about it before 0.1.89
fn classify() -> int {
  let done = false
  match done {
    true => { return 1 },
    false => { return 0 },
  }
}

// no Glyph diagnostic, then
[TS2678] Type 'true' is not comparable to type 'false'.

A type declared as a set of string literals failed the same way, and so did ==, which came back as TS2367 instead: two messages, two codes, one cause. The app that hit it was polling a flag a timer callback sets, which is how you turn an event-based API into a value while Glyph has no Promise you can construct by hand. The callback turned out to be beside the point: four lines with no callback do it.

The fix is not a new diagnostic, because there is nothing to diagnose. The emitter now restates the type the checker already decided, at the two places that discriminate over a value: the match scrutinee and either side of a == or !=. Each side is decided by its own type and ignores what sits next to it, so a comparison between two bindings is covered the same way a comparison against a literal is.

The checks you want still fire. m == "nope" where m is a Mode is still rejected, and now the message names Mode instead of the single member m happened to be holding. Doing this at the assignment instead would have been less code and would have thrown that away: a cast to Mode is accepted where the annotated assignment is not, and the membership check is the part Glyph leaves to tsc on purpose.

What it costs is small and worth naming. A cast can narrow as well as confirm, so a value Glyph types as bool whose TypeScript type came out unknown now passes where the switch used to complain. The two types still have to be comparable, so a model that has drifted far enough to matter, string against bool, reports TS2352 rather than nothing.

The same typo, two answers

A misspelled standard-library function used to get a different answer depending on how you had written the import, which is the kind of inconsistency that teaches you not to trust either answer.

the named import, always an E0105
import std/string { repeeat }
[E0105] import: `repeeat` is not exported by `std/string`
the namespace read, before 0.1.72
import std/string
string.repeeat(s, 2)
[TS2551] Property 'repeeat' does not exist on type ... Did you mean 'repeat'?

The first spelling was checked against the module's export list during resolution. The second was not checked at all, so it fell through to tsc, which saw a property access on an object and answered in those terms. Both spellings go through the export list now, and both say E0105, naming the member and the module it is missing from.

Switching it on immediately found something nobody was looking for: a test fixture had been calling fs.write, where the function is write_text. It had passed for months because that fixture runs with type-checking off, and nothing else in the compiler looked at namespace members. The export list is now the authority for both spellings, so a separate check keeps it in step with what the runtime actually exports. That check was written, then deliberately broken to confirm it failed, which is how we found its first version had been silently checking nothing.

Where it stands

Shipping today

E0222 for await outside an async fn, E0223 for a value-position match arm that produces no value, E0008 for an assignment missing mut, E0010 for a variant given several positional payload fields where the record form is the one that exists, E0302/E0303 for a ? in a position it cannot propagate from, E0110/E0111 for a declaration that would shadow a name the emitted module already depends on, E0104 for a local import that names no module under the build root, E0105 for a name a module does not export, whether you reached it through a named import or off a namespace, E0228 for a type's name written where a value is expected, E0204/E0211 for a prelude container assigned to or passed as a primitive, including the Nullable<T> case tsc narrows away, the same two codes for a value that is not in a string-literal union's declared set, E0011 for a construct nested past 64 levels, E0101 for a relative import path, which used to stop in the parser as a generic "expected module path segment", and E0113 for a project module declared under std/ or extern/, which used to compile clean and be unreachable. The shadow list covers types the emitter writes on its own, so Issue and Record are caught at the declaration. Every code has a one-line fix, a note explaining the rule, and a long-form glyph --explain body; docs/error-codes.md lists the catalogue and a test keeps the two in step.