Repository navigation
Rust JSG: align Lenient, NonCoercible and Nullable with C++ JSG #8057
Description
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.
nullalways becomesLenient::Nullwithout consultingT, 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
Nullablehad three states.
Nothing outside tests readsNullorUnconvertable.
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>>>, forR2ListOptions::include
(api/r2-bucket.h:458);Vec<Nullable<T>>, for theindexesandblobsof 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
FromJScan produce with a shallow type
check, which theExportedHandlerfields need. - WebIDL coercion in
#[jsg_oneof], as for C++
kj::OneOf<kj::String, X>.
Activity
guybedford commentedon Oct 28, 2026
This is a great analysis thanks for putting it together. Agreed on all the points here.