Skip to content

Make native Web API object types private #403

Open

Description

@jderochervlk

Goal

Browser objects must come from native constructors, factories, accessors, or checked conversions. A plain ReScript record must not type-check as a Response, HTMLCanvasElement, canvas context, or another native instance.

Every re-export of a native instance type should be private, with the same protection in its backing definition. Use an abstract type when no record fields need to be exposed. Keep type identity, inheritance, generic parameters, and the native JavaScript representation intact.

This follows #237 and #402. PR #402 standardizes explicit record re-exports without changing existing privacy. This issue makes the broader privacy policy consistent across the package.

What stays constructible

Do not make every record in the package private. Callers must still be able to build options, initializers, descriptors, callback dictionaries, and ordinary data values that the Web API accepts. Examples include Request.requestInit, Response.responseInit, event initializers, canvas context settings, and stream underlying-source dictionaries. Record these intentional exceptions in the inventory rather than inferring privacy from a module name.

A native object may have a public constructor binding. That is allowed: it creates the real platform object, not a lookalike record. The utility-only Canvas module does not need an artificial instance type.

Current evidence

At main 844c32af, Response already has a private backing record and a private public re-export. It is a regression baseline, not an unfixed example.

HTMLCanvasElement is still a public record with mutable dimensions. There are 80 private-record TODOs across 56 source files, but the audit must also find unmarked cases.

Compiler probes on ReScript 13.0.0-alpha.5 confirmed that a plain alias preserves an already-private record's restrictions. An explicit private re-export can restrict construction through that view, but does not by itself repair a public backing definition or another public representation. Private-field mutation currently emits warning 111. Abstract types cannot be record-spread.

Implementation plan

1. Inventory native instances and caller-owned data

Use rescript.json to enumerate public modules and trace each instance type to its backing declaration. Include exported types in DOM and DOMTree, generic and inherited types, helper aliases, and types inferred from constructors/accessors.

Record the owning module, backing type, all re-exports, current privacy, writable properties, and legitimate creation paths. Separate native instances from intentionally constructible dictionaries/data. Check transitive exports so a private Module.t does not coexist with a public route that can manufacture the same instance.

2. Settle writable properties with a small pilot

Use an already-private Response, an HTML canvas obtained through the DOM, and a currently public mutable interface such as URL as the pilot.

Prefer typed native @set operations for writable properties when direct assignment to a private record produces warning 111. Existing canvas setters provide a precedent. Keep field reads and native mutation behavior; do not introduce wrapper objects, copies, or new unsafe casts. Compile intended consumer workflows with warning 111 treated as an error. Do not hide the problem with a blanket warning suppression or depend on an unreleased compiler fix.

Document the migration from direct assignment to setters before rolling the policy out broadly.

3. Convert backing definitions and every native re-export

Make native records private at their defining location and preserve privacy at every public re-export:

type t = Foo.thing = private {...Foo.thing}

Keep abstract types abstract and preserve generic parameters. Record spreading and inheritance must not accidentally recreate a public instance type. Add the necessary native setters alongside each conversion so supported property writes remain possible.

Work in reviewable batches: shared DOM/event types first, then DOM/HTML/SVG/canvas; Fetch/file/streams/storage/workers; and media/audio/device/navigation and remaining families. Reuse the existing folder structure and sources of truth. Avoid new include directives and unrelated overload renames.

4. Prove the restrictions through the installed package

Add diagnostic-matched negative consumer fixtures for direct record construction and record-spread copies of protected instances. Include Response, HTMLCanvasElement, canvas contexts, a mutable interface, and representative generic/inherited types. Exercise any public aliases or inferred return types that could bypass the intended restriction. A fixture must fail because of privacy, not a missing field or unrelated type error; verify representative fixtures compile when only that privacy restriction is relaxed in an isolated test copy.

Positive consumers must still construct options/initializer records, obtain real objects through supported APIs, read fields, use the new setters, and pass values between compatible base/derived and generic interfaces. Test a real Response in Node and real canvas creation, context use, and writable properties in Chromium. No public-network dependency is needed.

Add an inventory-based check that catches newly introduced public native records or re-exports. Keep explicit, documented exceptions for caller-owned data rather than relying on a blanket text replacement.

5. Document and validate the migration

Update contributor rules, public examples, the changelog, and the known limitations. Remove each private-record TODO only when its construction and mutation cases are covered. Preserve method and field documentation when rearranging declarations.

Run the checks serially where they share compiler output:

npm run format:check
npm run build
npm test
npm run check:features
npm run test:negative
npm run test:browser
npm run check:consumer
npm run build:docs
npm run check:docs-links

Acceptance criteria

  • All public native-instance types and their backing definitions/re-exports are private or abstract, with the inventory documenting intentional constructible exceptions.
  • Ordinary record construction and spread copies cannot manufacture protected instances, including through alternate public aliases and inferred types.
  • Native constructors/factories, field reads, supported writes, inheritance, and generic consumers still work. Intended consumer writes pass with warning 111 treated as an error.
  • Options, initializers, callback dictionaries, and intentional plain-data records remain constructible.
  • Packed positive/negative consumers and representative Node/Chromium regressions pass; the failures are proven to test privacy rather than unrelated errors.
  • No extra runtime wrappers/copies or new unsafe casts are introduced. The full validation above and the public documentation pass.
  • Migration guidance and contributor rules explain the policy and prevent new public native-instance records.

This is a compile-time API guarantee, not a security boundary against arbitrary JavaScript or deliberate unsafe casts. The work is a separate follow-up to #402; it does not become a gate for the existing #382 alpha milestone unless a maintainer explicitly changes that scope. This issue does not authorize merging or publishing a release.

Activity

  1. jderochervlk commented on Nov 8, 2026

    @jderochervlk
    CollaboratorAuthor

    The compiler pilot exposed a boundary that needs an explicit scope decision before the broad conversion.

    On the actual packed package from main 4888db2c, using ReScript 13.0.0-alpha.5 with warnings 8 and 111 treated as errors, this approach compiles without unsafe casts or raw JavaScript:

    type responseShape = {...WebAPI.Response.t}
    let original = WebAPI.Response.fromString("body")
    let ordinaryRecord: responseShape = {
      type_: original.type_,
      url: "",
      redirected: false,
      status: 200,
      ok: true,
      statusText: "OK",
      headers: original.headers,
      body: Null,
      bodyUsed: false,
    }
    let forged: WebAPI.Response.t = (ordinaryRecord :> WebAPI.Response.t)
    let readText = () => forged->WebAPI.Response.text

    Node confirmed the original is a native Response, the coerced record is not, and calling the compiled readText throws TypeError. Private records reject direct literals, record-update copies, and public re-exports of the private type, but ordinary structural coercion can still bypass that construction restriction. This is stronger than the previously documented caveat about arbitrary JavaScript or deliberately unsafe casts.

    Typed @set operations do work with warning 111 treated as an error, so the planned mutation path is viable.

    Two possible contracts:

    1. Proceed with private records and setters, preserving field reads, and explicitly document :> as an escape hatch for native instances. This is the narrower change and follows the planned representation.
    2. Require the stronger no-forging guarantee, which needs a different representation such as abstract instance types with explicit getters/setters, or suitable compiler support. That is a larger breaking change to direct field access and record re-exports.

    I am asking for the intended contract before implementing the repository-wide conversion. No source changes have been made yet.

  2. jderochervlk commented on Nov 8, 2026

    @jderochervlk
    CollaboratorAuthor

    The maintainer requested implementations of both alternatives, with a separate PR for each, before choosing a representation. Both start from main 4888db2cc601ad568b76af6ed2add60da0ad96cb and will target main independently, not as a stack.

    The private-record option is being developed on fix/403-private-native-records. It preserves field reads, uses typed setters for warning-free writes, and documents explicit :> coercion as an escape hatch.

    The opaque-type option is being developed on fix/403-opaque-native-objects. It replaces native instance field access with getters/setters and must reject caller-created records passed through ordinary checked coercion.

    Both must preserve constructible options/initializers, native JavaScript objects without wrappers or copies, supported constructor/factory workflows, and generic/inherited type relationships. Both need packed positive/negative consumers, real Node/Chromium checks, migration guidance, and independent review. The source inventory covers 291 public modules across 28 feature groups, including backing modules and inferred/native helper types rather than only the existing TODOs.

    The comparison will cover construction protection, the coercion boundary, setter/read ergonomics, migration cost, coverage and any remaining limitations. Neither alternative is selected for landing yet. The PRs will reference this issue without closing it automatically.

  3. jderochervlk commented on Nov 8, 2026

    @jderochervlk
    CollaboratorAuthor

    Both alternative PRs are now open against main:

    These are partial drafts, not completed implementations or merge recommendations. Work is continuing in independent family batches. The opaque pilot passing CI does not establish the package-wide guarantee, and the private draft must also clear its current formatting failure. Neither PR is stacked on the other, and neither closes this issue automatically.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions