Anonymous interface synthesis from inline parameter types#7
Merged
guybedford merged 2 commits intomainfrom Apr 29, 2026
Merged
Conversation
Hoist directly-inline `{ ... }` parameter types into named interfaces,
so consumers get a typed builder instead of a `&Object` they have to
construct manually.
The motivating shape — Cloudflare's `SendEmail.send(builder: { ... })`
— previously emitted as
pub async fn send_with_builder(this: &SendEmail, builder: &Object)
-> Result<EmailSendResult, JsValue>;
forcing every caller to assemble the JS object and the `string |
EmailAddress` polymorphism by hand. With this change ts-gen synthesizes
a `SendEmailBuilder` interface as if the user had declared
interface SendEmailBuilder {
from: string | EmailAddress;
to: string | string[];
subject: string;
// ...
}
interface SendEmail {
send(builder: SendEmailBuilder): Promise<EmailSendResult>;
}
and the existing pipeline lights up the rest: dictionary classification,
property setters, the bare-string-vs-object union expansion, the
required-prop-tracking builder, fallible `build() -> Result<_, JsValue>`.
## Naming
`<Parent><ParamSegment>` PascalCased. The parameter's own identifier
takes priority — `SendEmail.send(builder: ...)` → `SendEmailBuilder`,
`DispatchNamespace.get(name, args: ...)` → `DispatchNamespaceArgs`. Falls
back to the member's JS name when the parameter is destructured or
otherwise unnamed (e.g. `WorkflowInstance.sendEvent({ event })` →
`WorkflowInstanceSendEvent`).
Collisions add a numeric suffix: a class with two methods both taking
an `(options: { ... })` parameter produces `FooOptions` and
`FooOptions2`. The deduplication uses the same `used_type_names` set
that already tracks every other type in the module, so synthesized
names also avoid colliding with hand-written declarations.
## Hoisting scope
Only top-level inline literals in parameter position are hoisted.
Anonymous types nested inside generics, unions, arrays, or other
literals follow the regular type-mapping rules and erase to `Object`.
Inline literals inside a *hoisted* interface's body recurse using the
synthesized parent's name.
The synthesized interface goes through the *exact same* parse pipeline
as a hand-written `interface Foo { ... }`. No special path, no parallel
construction logic — `convert_interface_decl` and the new synthesis
site both call a shared `interface_from_signatures(name, type_params,
extends, signatures, ...)` helper that owns member conversion and
classification. Real interfaces and synthesized ones are
indistinguishable in the IR by design.
## CONVENTIONS
The convention catalogue gains an "Anonymous interface synthesis"
section explaining the rule and naming. The doc also drops its previous
numbered structure in favour of a plain table of contents linking to
section anchors — the numbering was bookkeeping that drifted whenever
sections moved, and the prose flow now uses anchor links between
sections directly.
## Tests
* All 9 fixture snapshots re-blessed; the workers-types diff shows the
expected synthesized types: `SendEmailBuilder`, `AiGatewayOptions`,
`D1PreparedStatementOptions` / `...Options2`, `DispatchNamespaceArgs`,
`WorkflowInstanceSendEvent`. Each gets the full dictionary-builder
treatment.
* Integration tests pass: 6 (es_module_lexer) + 12 (node_console).
* `just clippy` and `just fmt-check` clean.
Two extensions to anonymous-interface synthesis:
## Type-alias position
`type Foo = { ... }` now promotes to `interface Foo { ... }` instead
of falling through to a `pub type Foo = Object` alias. This is the
same machinery as the parameter-position case (one
`InterfaceDecl` constructed via `interface_from_signatures`), just
keyed on the alias's own name.
A pile of workers-types definitions like
type R2Range = { offset?: number; length?: number; suffix?: number };
type AiSearchSearchRequest = { query: string; ... };
now emit as proper interfaces with the dictionary-builder treatment
they always wanted.
## Union of inline literals → structural merge
When every branch of a union is itself an inline `{ ... }` (the
typical TypeScript discriminated-union shape), the branches are
merged into a single interface body before synthesis. Per-property
rules:
* Required iff required in every branch (present + non-optional);
optional otherwise.
* Type is the union of the branch types where the property appears,
going through the regular union LUB / JsValue erasure.
* Writable iff writable in every branch (any `readonly` branch
downgrades the merged property to read-only).
* Methods of the same name keep all signatures and flow through
signature flattening.
* Index signatures dedup by structural equality.
Concretely for the email d.ts:
type EmailAttachment =
| { disposition: "inline"; contentId: string; filename: string; ... }
| { disposition: "attachment"; contentId?: undefined; filename: string; ... };
now produces a single `interface EmailAttachment` whose `contentId`
property is `Option<JsValue>` (required-in-inline + optional-in-attachment
= optional in the merge), with all the common properties promoted to
typed getters and setters.
## CONVENTIONS
The "Anonymous interface synthesis" section now covers both positions
(parameter + alias) and the union-of-literals merge rules with a worked
EmailAttachment example.
## Tests
* All 9 fixture snapshots re-blessed; the workers-types diff is large
because every previously-erased type alias now lifts to an interface.
* Integration tests pass.
* `just clippy` and `just fmt-check` clean.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Hoist directly-inline
{ ... }parameter types into named interfaces, so consumers get a typed builder instead of a&Objectthey have to construct manually.The motivating shape — Cloudflare's
SendEmail.send(builder: { ... })— previously emitted asforcing every caller to assemble the JS object and the
string | EmailAddresspolymorphism by hand. With this change ts-gen synthesizes aSendEmailBuilderinterface as if the user had declaredand the existing pipeline lights up the rest: dictionary classification, property setters, the bare-string-vs-object union expansion, the required-prop-tracking builder, fallible
build() -> Result<_, JsValue>.Naming
<Parent><ParamSegment>PascalCased. The parameter's own identifier takes priority —SendEmail.send(builder: ...)→SendEmailBuilder,DispatchNamespace.get(name, args: ...)→DispatchNamespaceArgs. Falls back to the member's JS name when the parameter is destructured or otherwise unnamed (e.g.WorkflowInstance.sendEvent({ event })→WorkflowInstanceSendEvent).Collisions add a numeric suffix: a class with two methods both taking an
(options: { ... })parameter producesFooOptionsandFooOptions2. The deduplication uses the sameused_type_namesset that already tracks every other type in the module, so synthesized names also avoid colliding with hand-written declarations.Hoisting scope
Only top-level inline literals in parameter position are hoisted. Anonymous types nested inside generics, unions, arrays, or other literals follow the regular type-mapping rules and erase to
Object. Inline literals inside a hoisted interface's body recurse using the synthesized parent's name.The synthesized interface goes through the exact same parse pipeline as a hand-written
interface Foo { ... }. No special path, no parallel construction logic —convert_interface_decland the new synthesis site both call a sharedinterface_from_signatures(name, type_params, extends, signatures, ...)helper that owns member conversion and classification. Real interfaces and synthesized ones are indistinguishable in the IR by design.CONVENTIONS
The convention catalogue gains an "Anonymous interface synthesis" section explaining the rule and naming. The doc also drops its previous numbered structure in favour of a plain table of contents linking to section anchors — the numbering was bookkeeping that drifted whenever sections moved, and the prose flow now uses anchor links between sections directly.
Tests
SendEmailBuilder,AiGatewayOptions,D1PreparedStatementOptions/...Options2,DispatchNamespaceArgs,WorkflowInstanceSendEvent. Each gets the full dictionary-builder treatment.es_module_lexer) + 12 (node_console).just clippyandjust fmt-checkclean.