Skip to content

Anonymous interface synthesis from inline parameter types#7

Merged
guybedford merged 2 commits intomainfrom
anonymous-interface-synthesis
Apr 29, 2026
Merged

Anonymous interface synthesis from inline parameter types#7
guybedford merged 2 commits intomainfrom
anonymous-interface-synthesis

Conversation

@guybedford
Copy link
Copy Markdown
Contributor

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.

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.
@guybedford guybedford merged commit 70964e9 into main Apr 29, 2026
4 checks passed
@guybedford guybedford deleted the anonymous-interface-synthesis branch April 29, 2026 05:07
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