Skip to content

Rust JSG: align Lenient, NonCoercible and Nullable with C++ JSG #8057

Open

Description

@piscisaureus

Rust JSG has four WebIDL argument wrappers. Each has a C++ JSG
counterpart:

Rust C++
Option<T> jsg::Optional<T>
Nullable<T> kj::Maybe<T>
NonCoercible<T> jsg::NonCoercible<T>
Lenient<T> jsg::LenientOptional<T>

Option matches C++. The other three have drifted from what C++ does
or cannot express what the C++ APIs that use them need. No production
Rust code uses any of them yet, so they are cheap to change now. These
are five proposed changes, each small enough for its own PR.

1. Narrow Lenient<T> to value-or-absent

C++ LenientOptional<T> is a kj::Maybe<T>. undefined is absent;
anything else, null included, goes through T's conversion, and a
type mismatch is absent too (jsg/value.h:582). For example,
value-test.c++:141 checks that takeLenientOptional(null) is absent
for a resource type, while a kj::String gets "null".

Rust's Lenient<T> (src/rust/jsg/lenient.rs) has four variants:
Some, Unconvertable(TracedGlobal<Value>), Null and Undefined.

  • null always becomes Lenient::Null without consulting T, which
    contradicts the C++ behaviour above.
  • A mismatch keeps the rejected value in a TracedGlobal, so every
    Lenient<T> reports that it holds GC references.
  • The extra variants date from when Nullable had three states.
    Nothing outside tests reads Null or Unconvertable.

The C++ users are 14 handler fields of ExportedHandler,
BenchExportedHandler and QueueExportedHandler
(api/global-scope.h:516-555, :641, api/queue.h:429) and one
Ref<Fetcher> parameter (api/wrapped-binding.h). None of them tells
null apart or looks at the rejected value.

Proposal: make Lenient<T> value-or-absent. null goes through T,
output is the value or undefined, and tracing delegates to T.
Optionally rename it to LenientOption<T>, which shows that it is an
Option and costs nothing while there are no users.

2. Treat Lenient fields as optional in #[jsg_struct]

C++ JSG_STRUCT treats a LenientOptional field like an Optional
one: a missing field is not an error, and an unset field is left out
of the output object. #[jsg_struct] treats only Option that way.
This could be folded into 1.

3. Drop the T: Type bound on Vec<T>'s FromJS

impl FromJS for Vec<T> (src/rust/jsg/wrappable.rs:1166) requires
T: Type, which the wrappers do not implement. So these do not
compile, though C++ APIs take them:

  • Option<Vec<NonCoercible<String>>>, for R2ListOptions::include
    (api/r2-bucket.h:458);
  • Vec<Nullable<T>>, for the indexes and blobs of an Analytics
    Engine data point (api/analytics-engine.h:38,40).

Proposal: drop the bound and add struct-field tests shaped like the R2
and actor option structs.

4. Narrow NonCoercible<T> to strings, bool and f64

C++ NonCoercible<T> exists for kj::String, USVString,
DOMString, bool and double (jsg/value.h:1246). Of its 33 uses
in api/, all are strings except two double fields
(api/actor.h:282,285).

Rust's NonCoercible<T> accepts any T: Type: every integer, i64
and u64 (which then accept only a BigInt), Vec, structs and
oneofs. None of these has a C++ precedent or a WebIDL meaning.

Proposal: limit T to a sealed trait implemented by String, bool
and f64. This builds on #8054, which gives f64 a FromJS.

5. Make Nullable<T> honour dont_substitute_null_on_type_error

For a worker whose compatibility date is before 2022-06-01, C++ turns
a type mismatch on a kj::Maybe<T> into null
(dont_substitute_null_on_type_error, io/compatibility-date.capnp).
Rust's Nullable<T> always throws. Ported APIs would then behave
differently for old workers.

Proposal: read the flag where Nullable<T> converts, as C++ does.

Related gaps

Two more things block porting the C++ users of these wrappers, and
are not part of this issue:

  • A Rust function type that FromJS can produce with a shallow type
    check, which the ExportedHandler fields need.
  • WebIDL coercion in #[jsg_oneof], as for C++
    kj::OneOf<kj::String, X>.

Activity

  1. guybedford commented on Oct 28, 2026

    @guybedford
    Contributor

    This is a great analysis thanks for putting it together. Agreed on all the points here.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions