Skip to content

N-ary functions: make arity structural across the compiler - #8557

Draft
cristianoc wants to merge 10 commits into
masterfrom
nary-functions
Draft

N-ary functions: make arity structural across the compiler#8557
cristianoc wants to merge 10 commits into
masterfrom
nary-functions

Conversation

@cristianoc

@cristianoc cristianoc commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Functions are n-ary, arity is structural

This branch completes the transition started by uncurried-by-default: functions and arrow types are now n-ary at every level of the compiler — parsetree, typedtree, and Types — and a function's arity is the length of its parameter list rather than an int option annotation on the head of a curried chain. Locally abstract types follow the same principle: a function's newtypes are a field on the function node, and let f: type a. t = e is a structural field on the binding, replacing the wrapper-node encodings.

Why. The curried encoding survived only in the frontend, inherited from OCaml, and every layer paid for it: arity lived in three places that could disagree (the term, the type, and the backend's .cmj), and some 30 hand-rolled "walk the chain until the arity marker" loops existed across the compiler, genType, reanalyze, and the editor tooling. Where they disagreed, the results were real bugs (see below). With structural arity, "declared arity = runtime arity" holds by construction, and the machinery that existed to enforce, compensate for, or work around the old encoding is deleted rather than maintained.

Benefits

Soundness and correctness fixes (each found by the refactor, each pinned by a test):

  • Interface/module inclusion and :> coercion ignored arity, so a curried int => int => int could satisfy (int, int) => int; a first-class use of such a value then miscompiled (typed int, runtime closure). Now a compile error with a dedicated message.
  • Optional-parameter defaults in curried functions were computed at the wrong application step ((~x=d, y) => (~z=d, w) => ... deferred x's default to the inner application).
  • ~x: int => string (unparenthesized) printed identically to (~x: int) => string but did not unify with it.
  • Five PPX round-trip fidelity bugs: leaked internal @res.async attributes, dropped arrow/await attributes (one crashing the formatter with a stack overflow), lost JSX closing tags, assert false on PPX-emitted OCaml-style function.

Formatter fidelity (from the structural newtypes):

  • Attributes keep their association with their type parameter group: (@attr type t, x, @attr2 type s, y) round-trips as written instead of printing @attr @attr2 on the function.
  • Comments written next to a type parameter travel with it to the hoisted group instead of migrating onto the following value parameter; a trailing comment between a type a. constraint and = is no longer dropped.

Correct docgen output: the structured detail produced by rescript-tools doc was corrupt — nested arrows flattened into extra parameters, labels and optionality were discarded, tuples and type variables vanished and shifted the parameter/return split, and non-functions got fabricated zero-parameter signatures. Details are now built from the same normalized outcome tree as the printed signature: parameters carry label/optional, types are recursive nodes (constructor/variable/tuple/function/rendered), and only functions get signature details. This is a breaking change to the published RescriptTools.Docgen JSON schema (see Risks).

Better generated code:

  • No more adapter closures when a parameter pattern touches mutable fields: the old currying split emitted immediately-applied closure chains per call ((param => {...})(a)(b)); these are gone (see mutable_uncurry_test.mjs).
  • Recursive modules whose members are plain functions now compile statically — plain hoisted functions instead of the Primitive_module.init/update runtime bootstrap (see rec_module_test.mjs). The bootstrap remains where it is load-bearing.
  • The Pjs_fn_make wrapper acted as an accidental optimization barrier: early Lambda passes saw an Lprim where a function was. With it gone, user variable names survive more often and constants propagate (e.g. param_0/param_1 become the user's u/v).
  • Optional-parameter internals get informative names ($staropt_dir$star instead of $staropt$star$1) in the rare unprettified case.

Better error messages: arity mismatches report precise unlabelled-argument counts; missing-argument lists print in source order; the confusing "This labeled function is applied to arguments in an order different from other calls" restriction is gone (labels commute for inferred functions too, soundly).

Less compiler, with test coverage added. Deleted outright: the parsetree arity annotation and ast_uncurried.ml; push_defaults; the Pjs_fn_make/Pjs_fn_make_unit primitives and the 230-line unsafe_adjust_to_arity; the gather-until-arity walkers in genType (×2), reanalyze (×2), the outcome printer, and the editor tooling; the unreachable Too_many_arguments error and its ?in_function plumbing; the parser/printer mirrored @as-arity hacks; the Pexp_newtype/Texp_newtype wrapper encoding, the parser's wrap_type_annotation double-type dance, and the '?'-in-string label smuggling in Otyp_arrow (plus the dead Octy_arrow).

Better tooling output: signature help no longer includes the opening paren in the first parameter's range; genType recovers real parameter names after defaulted parameters; reanalyze stops emitting spurious empty optional-argument references.

Risks

  • One deliberate breaking change: arity mismatches in inclusion/coercion are now compile errors. Code relying on the old leniency was one data-structure hop away from miscompiling (the soundness fix above); nothing in the compiler, stdlib, or test corpora relied on it.
  • One breaking output-format change: the detail JSON emitted by rescript-tools doc and the published RescriptTools.Docgen types changed shape (the old shape was unusable — see Benefits). The documentation site does not consume detail; third-party consumers must adapt.
  • One intentional narrowing of the PPX surface: with Pexp_newtype removed from the current parsetree, a v0 locally-abstract-type wrapper that the bridge cannot represent (e.g. PPX-synthesized fun (type a) -> with no arity wrapper, or a type a. molecule a PPX perturbed) becomes a located ocaml.error extension with an explicit message, instead of passing through. Compiler-produced shapes round-trip exactly (unit-tested, including the diagnostic).
  • One semantic change in an edge case: @this this => async arg => ... now means what it says (a method returning an async function) instead of the old chain-walk absorbing the nested lambda's parameter into the method. Relatedly, an attribute written in front of a type-first arrow (@this (type t, x) => ...) now lands on the function node and takes effect; it previously sat inert on a wrapper node.
  • Binary format bumps: cmi magic is I023, cmt magic is T024; clean builds are required, and cmt-consuming tools must be rebuilt in lockstep (all in-tree consumers are updated here).
  • The rewrites with the largest blast radius are type_function and type_application in typecore and transl_function in translcore. Mitigations: generated JS is byte-identical across the stdlib and the test corpus except for the deliberate improvements listed above; the full suites (syntax round-trip, super_errors, build tests, gentype, analysis, tools, ounit) pass at every commit; an adversarial corpus covers label commutation, optional inference, partial application, and the reject-side of every closed soundness hole.
  • PPX wire format: byte-compatible for compiler-produced code (verified by a round-trip corpus added in this branch), with observable nuances for PPX authors: internal _res.arrow_node_attrs and _res.newtype_attrs markers appear when a node's attribute split must survive the single v0 attribute slot; Has_arityN now always equals the arrow-chain length (previously not true for @as-phantom externals); and the synthesized newtype/constraint wrapper nodes carry slightly different location values than the old parser produced (structure and attributes are exact; verified by loading both wires through the same frontend).
  • Verification so far is single-platform (macOS/ARM). This draft exists to get the CI matrix and ecosystem projects (especially PPX-heavy and editor-heavy setups) onto it. Note make test-rewatch is red on master itself (the vendored sury uses the removed Js namespace) — unrelated to this branch.

Intended PR series

Each commit builds and passes the full suite independently; the sequence below is the extraction plan.

  1. ebfda40d0Enforce function arity in inclusion, type equality, and coercion (the soundness fix; independently landable now)
  2. ab2699f32Harden the Parsetree0 PPX bridge and add a round-trip corpus (bug fixes + the safety net for 4; independently landable)
  3. e019ed3f0Make parsetree arrow arity honest (removes the @as arity fudge and its printer compensation; independently landable)
  4. 8de13f567Make functions and arrow types n-ary in the parsetree (typed layers untouched; should soak before 5)
  5. d5cf39ca9Make the typed layers n-ary (Tarrow/Texp_function params; cmt+cmi bump; downstream tools adapt in lockstep)
  6. 20afe9810Remove dead code enabled by structural arity (rides with 5)
  7. 8c402aea2Eliminate Pjs_fn_make, Pjs_fn_make_unit, and unsafe_adjust_to_arity (rides with 5; contains the recursive-module rationale)
  8. e2202fb8eMake a function's locally abstract types part of the function node (rides with 4/5; the formatter fidelity fixes)
  9. 716cb1f0aMake locally abstract value constraints structural in the parsetree (rides with 8; deletes Pexp_newtype/Texp_newtype, bumps cmt to T024, contains the PPX-surface narrowing)
  10. c3e084f0bPreserve structure in docgen function details (structured Otyp_arrow labels + the docgen schema change; landable any time after 5)

Of the items originally deferred here: docgen precision and structured Otyp_arrow labels landed as commit 10; per-parameter newtypes resolved into commits 8–9 after design review (front-hoisting is intentional normalization, so newtypes became structural fields rather than positional parameters, mirroring what OCaml 5.1/5.2 did with Pvc_constraint and type_newtype); optionality-as-a-parameter-field was analyzed and declined — the churn outweighs the payoff, and the v0 wire keeps Optional labels regardless.

🤖 Generated with Claude Code

cristianoc and others added 6 commits August 17, 2026 10:14
Add the arity guard (already present in unify) to the Tarrow cases of
moregen, eqtype, and subtype_rec. Previously a curried implementation
(int => int => int) could satisfy an uncurried interface
((int, int) => int) through signature inclusion or :> coercion; calls
made through the interface type compile to direct JavaScript calls with
the declared arity, so a first-class use of such a value miscompiled
(e.g. returning a closure where an int was expected).

The value-mismatch report in includemod now prints a dedicated hint
when the two sides are functions of different arities, replacing the
vestigial empty curry_kind slot.

mcomp is deliberately left arity-lenient: it is an incompatibility
oracle for pattern and GADT reasoning, where leniency errs toward
"possibly compatible".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Cristiano Calcagno <cristianoc@users.noreply.github.com>
The v0 bridge (ast_mapper_to0 / ast_mapper_from0) had several fidelity
bugs that surfaced whenever code passed through an external PPX:

- the internal res.async marker leaked back into the program as a real
  attribute after decoding;
- attributes on an arrow-type node were merged into the argument's
  attribute list on the way back, which crashed the formatter with a
  stack overflow; the two lists are now kept separable with an internal
  _res.arrow_node_attrs marker, and the arrow_type viewer additionally
  always consumes the head argument so it can never return its input as
  the "return type";
- the await node's own attributes were dropped entirely (losing e.g.
  @outer in "@outer await (@inner e)" and res.braces on async bodies);
  res.await now serves as the boundary between await-node attributes
  and inner-expression attributes;
- JSX container elements were rebuilt without a closing tag, printing
  unclosed elements; a closing tag matching the opening tag is now
  synthesized;
- PPX-emitted OCaml-style `function | p -> e` hit assert false; it is
  now desugared to `fun x -> match x with ...` like the OCaml parser
  would.

Marshaled current-parsetree streams (-as-pp, res_parser -print binary,
Ast_mapper.apply_lazy) now carry their own magic numbers
(ResImpl01300/ResIntf01300); the Caml1999M022/N022 pair is reserved for
the frozen Parsetree0 wire format that external PPXes rely on.

Round-trip sweep over all 350 syntax test files: 37 diverging files
before, 21 after, no regressions; every arrows/functions/async/await
file now round-trips byte-identically. New ast-mapping corpus file
FunctionsAndArrows.res pins the constructs, and a unit test covers the
function-cases desugaring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Cristiano Calcagno <cristianoc@users.noreply.github.com>
The parser deliberately decremented the head arity of an external's
arrow type for each labelled phantom argument (~x: @as(json`...`) _),
and the printer contained the mirror-image hack re-adding it. The
decremented number was consumed by nobody else: external processing
erases phantom arguments from the type and recounts the arity from the
kept arguments (process_obj returns List.length args, and the non-obj
path rebuilds the type with Typ.arrows, which stamps its own arity).

Remove the fudge and the compensation, along with the In_external
tracking module whose only purpose it was. The parsetree invariant is
now unconditional: a head arrow's arity equals the number of written
parameters.

Also fix bare labeled arrow types (~x: int => string) parsing with no
arity: they printed identically to the parenthesized form
((~x: int) => string) but did not unify with it.

Verified: AsInUncurriedExternals formatting is idempotent, its bridge
round-trip and generated JS are byte-identical, and the full test
suite passes with no .mjs drift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Cristiano Calcagno <cristianoc@users.noreply.github.com>
Replace the curried one-parameter-per-node encoding with n-ary nodes:

  Ptyp_arrow of {params: arg list; ret: core_type}
  Pexp_fun of {params: fun_param list; body: expression; async: bool}

where fun_param carries per-parameter attributes, label, default, and
pattern. The arity annotation is gone from the parsetree: a function's
arity is List.length params, unrepresentable wrong. ast_uncurried.ml is
deleted; Ast_helper.Typ.arrow and Exp.fun_ are list-first and assert
non-empty parameter lists.

The typed layers are unchanged: typetexp folds the params list into the
existing curried Tarrow/Ttyp_arrow chains (Some arity on the head, None
inside), and typecore peels parameters one at a time, reproducing the
legacy per-level type_function calls; synthesized rest-functions carry
an internal #res.fun_rest attribute consumed immediately on re-entry.

The Parsetree0 bridge re-curries on the way out (byte-identical wire
format for external PPXes) and gathers Has_arityN / res.arity groups
back into one node on the way in; bare PPX-fabricated v0 funs decode as
one-parameter functions instead of the old, mostly unusable arity-None
encoding.

Attribute contract: in-parens parameter attributes stay on the
patterns (as before); arrow-level attributes (@attr (a, b) => ...) live
on the function node; p_attrs is populated only by the PPX bridge.

Printing is byte-identical across the syntax test corpus; generated
JavaScript is byte-identical across the test suite except
UncurriedExternals.res, where `@this this => async arg => ...` now
honors the written nesting (a method returning an async function)
instead of absorbing the nested lambda's parameter into the method -
the group-boundary ambiguity this representation removes. Signature
help no longer includes the opening paren in the first parameter's
highlight range, and completion debug traces lose their synthetic
chain-node lines.

Also fixes three latent Typ.arrow-on-empty-params paths (zero-argument
externals, @deriving(accessors) zero-argument constructors in
signatures, parser error recovery) that the old arrows helper silently
absorbed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Cristiano Calcagno <cristianoc@users.noreply.github.com>
Types.Tarrow carries a parameter list (Tarrow of arg list * type_expr);
Texp_function carries typed parameters {fp_lbl; fp_param; fp_pat;
fp_partial} and a body; Ttyp_arrow and Otyp_arrow follow. The arity
annotation and its int-option phantom state are gone from the compiler.

Type relations compare parameters pairwise; a length mismatch is
structural incompatibility (which also makes mcomp's arrow verdict
sound: arrows of different lengths can never unify). filter_arrow
becomes filter_arrow_n. type_function types all parameters against one
arrow, checking expected labels up front to preserve the dedicated
Abstract_wrong_label diagnostics; optional-parameter defaults desugar
to uniquified *opt_<label>* bindings stacked at the head of the body.
type_application is a single parameters-to-arguments matching loop
preserving the legacy commutation, optional auto-fill, eta-expansion
placeholder, and error-selection behavior.

translcore's push_defaults is deleted (defaults now sit in the body by
construction) and transl_function walks the parameter list, keeping the
active-pattern split. Downstream, the gather-until-arity walkers in
gentype and the outcome printer, reanalyze's two arity-corrective
helpers, and typedecl's structural arity fallback are all deleted.

The cmi and cmt magic numbers are bumped to Caml1999I023/Caml1999T023.

Generated JavaScript is byte-identical across the test suite except:
- a bug fix: defaults of optional parameters in curried functions are
  now computed when their own parameter group is applied ((~x=d, y) =>
  (~z=d, w) => ... no longer defers x's default to the inner
  application); pinned by the uncurried_default.args snapshot;
- optional-parameter internals are named *opt_<label>* instead of
  *opt* in the one unprettified case (mario_game).

Error-message improvements: method arity mismatches report unlabelled
argument counts precisely, and missing-argument lists print in source
order. Reanalyze no longer emits spurious empty optional-argument
references; genType recovers real parameter names after defaulted
parameters.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Cristiano Calcagno <cristianoc@users.noreply.github.com>
- The Too_many_arguments error cannot be raised anymore: the expected
  type is committed to an arrow of the literal's shape before
  destructuring, so every legacy path now surfaces as a regular type
  clash or Uncurried_arity_mismatch (which is what the fixture credited
  to it in ERROR_VARIANTS.md was already producing). Remove the
  variant, its printer, and the ?in_function threading through
  type_expect/type_cases that existed only to decorate it.
- Remove the function$-vs-arrow unification bridge in ctype (nothing
  produces a function$ type expression anymore), the structural arity
  counter Ctype.arity (no callers), the parsetree arity probes
  get_uncurry_arity/get_curry_arity (is_arity_one reads the params list
  directly), and Ast_async's redundant newtype double-dig.
- Deduplicate the arrow-flattening step shared by the analysis
  extract_function_type helpers.

Generated code is unchanged. The Pjs_fn_make no-op elision explored
alongside these cleanups is deliberately left out pending a dedicated
analysis of that primitive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Cristiano Calcagno <cristianoc@users.noreply.github.com>
With structural arity, "function of arity n" is a construction
invariant rather than a goal state: translcore builds every Lfunction
with exactly the parameters its type declares. The arity-enforcement
layer therefore disappears:

- Function literals are emitted directly; the Pjs_fn_make wrapper that
  every function passed through was resolved as a no-op by
  lam_pass_alpha_conversion, but only *after* deep_flatten,
  simplify_exits and simplify_alias had run with the function hidden
  inside an Lprim, acting as an accidental optimization barrier.
- Pjs_fn_make_unit was a one-bit metadata channel: its entire effect
  was setting one_unit_arg so js_exp_make drops the unit parameter.
  translcore now sets the attribute directly, gated on the parameter
  pattern binding no identifiers (a () or _ pattern) - a more
  principled test than the alpha pass's check that the parameter was
  named "param".
- The active-pattern currying split in transl_function is deleted. It
  preserved pattern-effect timing across curried application steps,
  which no longer exist: total applications supply all arguments at
  once and explicit partial application eta-defers the entire call.
  The old output proves the point - the split's closures were
  immediately applied by the arity adapter, so only the allocations
  are gone (see mutable_uncurry_test).
- The I<N> unboxed-record producer (the @this method-callback
  encoding) is removed: the general Record_unboxed translation already
  returns the single field unboxed, and the wrapped value is a literal
  of matching arity.

With no producers left, both primitive constructors and every consumer
arm are deleted, including the 230-line unsafe_adjust_to_arity (its
only callers were the two Pjs_fn_make resolution sites).

On recursive modules: removing the wrapper lets the static
recursive-module compilation path see module members that are plain
functions, replacing the Primitive_module.init/update bootstrap with
hoisted function declarations. This is safe because the static path's
own applicability check now sees the functions it was designed to
check - the wrapper was hiding them, pessimizing compilation - and the
bootstrap demonstrably remains for members that are not plain
functions (rec_module_test keeps its lazy/value cases dynamic).

Verified: stdlib byte-identical; full test suite green; JS output
changes limited to removed adapter closures, removed no-op module
bootstraps, better name preservation, and constant propagation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Cristiano Calcagno <cristianoc@users.noreply.github.com>
@codecov

codecov Bot commented Aug 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 82.85928% with 229 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.69%. Comparing base (0c8e5b4) to head (c3e084f).
⚠️ Report is 3 commits behind head on master.

Files with missing lines Patch % Lines
compiler/ml/typecore.ml 86.02% 38 Missing ⚠️
compiler/ml/printast.ml 0.00% 23 Missing ⚠️
compiler/ml/ctype.ml 64.58% 17 Missing ⚠️
compiler/ml/printtyped.ml 0.00% 12 Missing ⚠️
compiler/syntax/src/res_parens.ml 45.45% 12 Missing ⚠️
compiler/frontend/bs_ast_mapper.ml 38.88% 11 Missing ⚠️
compiler/syntax/src/jsx_v4.ml 84.28% 11 Missing ⚠️
compiler/ml/oprint.ml 0.00% 9 Missing ⚠️
analysis/src/type_utils.ml 68.00% 8 Missing ⚠️
compiler/ml/ast_mapper_from0.ml 92.00% 8 Missing ⚠️
... and 24 more
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #8557      +/-   ##
==========================================
+ Coverage   75.54%   75.69%   +0.14%     
==========================================
  Files         476      475       -1     
  Lines       62710    62878     +168     
==========================================
+ Hits        47374    47594     +220     
+ Misses      15336    15284      -52     
Files with missing lines Coverage Δ
analysis/reanalyze/src/arnold.ml 83.36% <100.00%> (+0.05%) ⬆️
analysis/reanalyze/src/dead_optional_args.ml 92.00% <100.00%> (+5.33%) ⬆️
analysis/reanalyze/src/dead_value.ml 85.77% <ø> (+0.69%) ⬆️
analysis/src/completion_back_end.ml 81.74% <100.00%> (+0.01%) ⬆️
analysis/src/completion_jsx.ml 75.00% <100.00%> (ø)
analysis/src/create_interface.ml 86.87% <100.00%> (-0.09%) ⬇️
analysis/src/process_cmt.ml 82.00% <100.00%> (+0.18%) ⬆️
analysis/src/shared.ml 68.18% <100.00%> (+0.73%) ⬆️
analysis/src/signature_help.ml 78.86% <100.00%> (+0.20%) ⬆️
analysis/src/utils.ml 54.21% <ø> (+0.32%) ⬆️
... and 75 more

... and 18 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@pkg-pr-new

pkg-pr-new Bot commented Aug 17, 2026

Copy link
Copy Markdown

Open in StackBlitz

rescript

npm i https://pkg.pr.new/rescript@8557

@rescript/darwin-arm64

npm i https://pkg.pr.new/@rescript/darwin-arm64@8557

@rescript/darwin-x64

npm i https://pkg.pr.new/@rescript/darwin-x64@8557

@rescript/linux-arm64

npm i https://pkg.pr.new/@rescript/linux-arm64@8557

@rescript/linux-x64

npm i https://pkg.pr.new/@rescript/linux-x64@8557

@rescript/runtime

npm i https://pkg.pr.new/@rescript/runtime@8557

@rescript/win32-x64

npm i https://pkg.pr.new/@rescript/win32-x64@8557

commit: c3e084f

@github-actions

Copy link
Copy Markdown

cristianoc and others added 3 commits August 17, 2026 12:57
Replace the Pexp_newtype wrapper chains that the parser built for
(type t, x) => ... arrow syntax with a structural field on the function
node: Pexp_fun.newtypes carries each newtype name with its own
attributes, hoisted in front of the value parameters as before.
Pexp_newtype remains solely as the desugaring of [let f: type a. ...]
annotations and for PPX-authored trees.

Fidelity fixes visible in the formatter:
- Attributes keep their association with their type parameter group:
  (@attr type t, x, @attr2 type s, y) round-trips as written instead of
  printing @attr @attr2 on the function.
- Comments written next to a type parameter travel with it to the
  hoisted group instead of migrating onto the following value parameter.
- Attributes written in front of the arrow now live on the function
  node, so built-in attribute processing (e.g. @this) sees them on
  type-first functions; previously they sat inert on the wrapper node.

Typing follows the upstream OCaml 5.x design: the newtype machinery is
extracted into a reusable type_newtype helper (mirroring OCaml's helper
of the same name) and the function case peels one newtype at a time,
mimicking the typing of the former wrapper chain; the typedtree output
is bit-identical to before.

The v0 PPX bridge expands the field back into a wrapper chain around
Function$: each wrapper carries its own newtype's attributes, and the
outermost wrapper separates function-node attributes from the first
newtype's attributes with an internal _res.newtype_attrs marker (no
marker means node attributes only, matching the historical wire).
Newtype-free programs are wire byte-identical; for functions with
newtypes the deltas are confined to wrapper-node locations and, for the
rare attributed groups, per-wrapper attribute placement. Identity-PPX
round-trips are AST-exact, verified against the previous compiler.

Also: jsx_v4 and bs_builtin_ppx now carry newtypes (and their
attributes) through their function rebuilds instead of dropping them,
the sexp AST debugger emits the field, and dead parser plumbing
(fundef param attrs/p_pos, arrow_start_pos, make_newtypes ~attrs) is
removed.

Signed-Off-By: Cristiano Calcagno <cristianoc@users.noreply.github.com>

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the desugared encoding of [let f: type a. t = e] - a Ptyp_poly
pattern constraint plus a Pexp_newtype chain over a Pexp_constraint,
with the type stored twice and no AST invariant ensuring that the copies
agree - with a structural field on the binding:

  pvb_constraint: {pvc_newtypes: string loc list; pvc_type: core_type}

Only the [type a.] form uses the field; plain constraints and explicit
polymorphic annotations keep their existing representation. The type is
stored once, and [varify_constructors] now runs in exactly one place,
inside the type checker.

With functions already carrying their locally abstract type parameters in
Pexp_fun.newtypes, this removes the last place where the parser constructs
Pexp_newtype. Delete the constructor from the current parsetree, along with
the Texp_newtype exp_extra, which had no consumer beyond no-op iterators and
the debug printer. The CMT magic number is bumped to Caml1999T024; the CMI
format is unchanged.

Type checking follows the same design as the function case (and OCaml
5.x): type_let introduces the locally abstract types into scope via
type_newtype, types the body against the constraint, and unifies with the
pattern's polymorphic type. This preserves the semantics of the former
desugaring.

The frozen v0 PPX bridge expands the field back into the historical
wrapper-chain encoding and recognizes well-formed instances of that
encoding on the way in, verified by unit tests. A v0 Pexp_newtype chain
that cannot be represented - such as one that does not enclose ReScript's
Function$ encoding, or one whose structure was changed by a PPX - now
becomes a located ocaml.error extension with an explicit message. This is
the only intentional reduction in accepted v0 PPX output.

Formatter bug fix covered by syntax fixtures: a trailing comment between
the constraint type and [=] is no longer dropped. An end-to-end GADT test
checks that refinement still works with the new binding field.

Signed-Off-By: Cristiano Calcagno <cristianoc@users.noreply.github.com>

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Function labels in Outcometree were strings, with optionality encoded by a leading question mark. That forced printers to decode the spelling and left downstream consumers without the label structure already known by the type system. Store Noloc.arg_label directly on Otyp_arrow, update both printers to match it exhaustively, and remove the unproduced Octy_arrow constructor.

The doc generator previously walked Types.type_expr independently and flattened every reachable constructor into one list. Nested arrows became outer parameters, tuples and type variables disappeared, labels and optionality were lost, and non-function values acquired fabricated zero-parameter signatures. Build details from the normalized Outcometree instead: parameters retain their metadata, constructors, variables, tuples, and functions form recursive nodes, uncommon forms remain visible through a rendered fallback, and only top-level arrows receive signature details.

Update the published RescriptTools.Docgen types and snapshots for the intentionally breaking JSON shape, and correct the implementation's stale alias tag to match the signature tag declared by its interface. The documentation site drops value details before publishing its data, but third-party consumers of rescript-tools doc need the changelog warning.

Focused fixtures cover labeled and optional parameters, generic variables, callbacks, tuple returns, returned functions, fallback rendering, and non-function values. Compiler, tools, analysis, syntax, roundtrip, and full test suites remain green.

Signed-off-by: Cristiano Calcagno <cristianoc@users.noreply.github.com>
@cristianoc

Copy link
Copy Markdown
Collaborator Author

@cknitt tried to put the entire overview together, in this draft PR, before splitting into individual PRs.
Any thoughts on how to proceed: if you have thoughts for extra testing before proceeding with this.

@cknitt

cknitt commented Aug 17, 2026

Copy link
Copy Markdown
Member

Any thoughts on how to proceed: if you have thoughts for extra testing before proceeding with this.

It's already good that CI is green.

I can also try to test against a large company project of ours tomorrow.

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.

2 participants