Skip to content

lang: opaque types — zero-cost newtypes - #10

Merged
simontreanor merged 1 commit into
mainfrom
opaque-types
Jul 27, 2026
Merged

lang: opaque types — zero-cost newtypes#10
simontreanor merged 1 commit into
mainfrom
opaque-types

Conversation

@simontreanor

Copy link
Copy Markdown
Owner

Implements Scala-3-style opaque types adapted to Pyfun: opaque type UserId = string (optionally parameterized, opaque type Tag a = List a) declares a zero-cost newtype — nominally distinct from its underlying type everywhere, fully erased in the emitted Python. DESIGN.md §7.3 is the canonical write-up.

Surface. Wrap with the same-named constructor (UserId : string -> UserId), unwrap with the single-case pattern (case UserId s:, payload position takes any pattern). opaque is a contextual keyword — only before type; let opaque = 1 still parses. Syntax chosen over Nim-style distinct and bare type X = int (Python 3.12's transparent-alias form — a false friend).

Checker. A newtype registers as a single-constructor sum sharing the type name, so inference, pattern checking, and Maranget exhaustiveness (witness UserId _) need no new rules. Distinctness errors read expected string, found UserId.

Erasure. No class emitted; UserId "u-1001""u-1001"; case UserId s: → bare case s:; first-class references (List.map UserId) → _pf_id; externs typed with a newtype hand Python the bare underlying value (the interop payoff over the single-case-DU idiom, whose wrapper object would leak across the boundary).

Match sealing fix. An erased pattern is irrefutable to Python, which rejects any later case as a SyntaxError — lowered matches are now sealed by seal_cases: truncate at the first unconditional lowered case, else append the defensive raise. Judged on PyPattern, not the source arm.

Cross-module. Newtypes export like sums, plus a newtype flag through the project ImportContext so consumers erase Ids.UserId construction/patterns/first-class refs identically. Verified by new project tests.

Tests. 29 added across roundtrip, typecheck, compile (shape + e2e), project (cross-module e2e + distinctness), and REPL. Full suite green; clippy clean.

Also. ROADMAP now tracks a pre-existing module-alias shadowing hazard found while testing (import Ids + let ids = … clobbers the emitted import ids; recommended small fix). Follow-up left for the editor grammars: opaque keyword highlighting (VS Code/tree-sitter), which rides the manual Marketplace republish.

`opaque type UserId = string` (optionally parameterized): a nominal type
distinct from its underlying everywhere, wrapped by the same-named
constructor and unwrapped by the single-case pattern, fully ERASED at
lowering — no Python class, wrap compiles to the bare value, first-class
constructor references compile to _pf_id, and externs typed with the
newtype pass the underlying value to Python unchanged.

Checker-side a newtype registers as a single-ctor sum sharing the type
name, so inference, pattern checking, and Maranget exhaustiveness need
no new rules; newtypes export cross-module like sums (with a newtype
flag in the project ImportContext so consumers erase too).

Erasure made one latent shape reachable: an erased single-case pattern
is irrefutable to Python, which rejects any later `case` as a
SyntaxError — lowered matches are now sealed by `seal_cases` (truncate
at the first unconditional LOWERED case, else append the defensive
raise), judged on PyPattern rather than the source arm.

`opaque` is a contextual keyword (only before `type`); Scala 3 spelling
chosen over Nim's `distinct`/bare `type X = int` (the latter is Python
3.12's transparent-alias syntax — a false friend).

Also: ROADMAP notes a pre-existing module-alias shadowing hazard found
while testing (`import Ids` + `let ids = ...` clobbers the emitted
`import ids`).
@simontreanor
simontreanor merged commit 199c8a9 into main Jul 27, 2026
11 checks passed
@simontreanor
simontreanor deleted the opaque-types branch July 27, 2026 08:44
simontreanor added a commit that referenced this pull request Jul 27, 2026
`opaque type` (PR #10) highlighting: tree-sitter rule
opaque_type_definition (+ regenerated parser, corpus entry, highlight
queries for the bundled/nvim/zed copies), VS Code TextMate keyword,
nvim syntax keyword, emacs keyword. Corpus expectation derived from
the grammar rules; tree-sitter test could not run on this machine (no
C compiler headers) - run it where a compiler exists before the next
grammar release.
simontreanor added a commit that referenced this pull request Jul 27, 2026
* lowering: alias a module import shadowed by a top-level binding

`import Ids` + `let ids = ...` emitted `ids = ...` after `import ids`,
clobbering the module object so any later qualified call broke at
runtime. Qualified-reference emission now routes through
`py_module_ref`: normally the plain lowercase module name (readable
output unchanged), but when a top-level binding claims that name the
import is aliased (`import ids as _pf_ids`) and every reference uses
the alias. Local-binder shadowing inside a single function remains and
is tracked in ROADMAP as residual.

* editors: opaque keyword in grammars

`opaque type` (PR #10) highlighting: tree-sitter rule
opaque_type_definition (+ regenerated parser, corpus entry, highlight
queries for the bundled/nvim/zed copies), VS Code TextMate keyword,
nvim syntax keyword, emacs keyword. Corpus expectation derived from
the grammar rules; tree-sitter test could not run on this machine (no
C compiler headers) - run it where a compiler exists before the next
grammar release.
@simontreanor simontreanor mentioned this pull request Jul 27, 2026
simontreanor added a commit that referenced this pull request Jul 27, 2026
Ships in the published wheel: opaque types (zero-cost newtypes,
`opaque type UserId = string` - PR #10), List.choose + List.collect
(PR #9), and the module-alias shadowing miscompile fix (PR #12).
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.

1 participant