gc
gc copied to clipboard
JS API and nominal/structural typing
The current text of MVP-JS.md goes to some lengths to describe a mechanism to preserve JavaScript's nominal typing through roundtrips through Wasm code. Maybe I'm missing something there... but right now I don't think that this is implementable. It seems to me that this is effectively introducing a fully nominal type system to Wasm-GC, and further does so by requiring each type to magically know whether it is supposed to behave structurally or nominally.
How is a Wasm module that imports a type supposed to know whether it is going to be instantiated from JavaScript, and not from some other environment? If any extra checks are performed only at the boundary (instantiation and value passing), what about module-internal function calls? Let me extend the given example by a helper function:
;; example4-modified.wat
(module
(type $Point (import "" "Point") (eq (struct (field $x i32) (field $y i32))))
(type $Rect (struct (field $x1 (ref $Point)) (field $x2 (ref $Point))))
(type $InternalPoint (struct (field $x i32) (field $y i32) (field $color f64)))
(type $HasPoint (struct (field $point (ref $InternalPoint))))
(func $Helper (param (ref $HasPoint))
(struct.set $HasPoint $point
(get_local 0)
(struct.new $InternalPoint (i32.const 10) (i32.const 20)))
)
(func (export "goWild") (param (ref $Rect))
(call $Helper (get_local 0))
)
)
We can check when goWild
is called that the argument is a proper $Rect
, but if the call to $Helper
performs regular structural typechecks, then that doesn't prevent the installation of an $InternalPoint
instance on the rect. How is that call supposed to determine that it must perform nominal type checks? Are all imported types supposed to have nominal identity? How does that mesh with subclassing? And how is that different from having to support nominal types in addition to structural types all across the system? And when compiling a Wasm function, how should the compiler decide whether to emit nominal or structural type checks?
If I'm just entirely misunderstanding things here, then please educate me.
In case my confusion/concern is valid: I don't have a fully-fledged proposal for an alternative. My general inclination would be to let ourselves be guided by the following principle: rather than introducing JavaScript's types to WebAssembly, the boundary between the two should be shaped such that it acts as an adapter between the different worlds. Maybe an approach that could work is that Wasm objects, when exposed to JavaScript, by default have structural type behavior and indexed access, and there's a way to have Proxy-like views on them to "look at them through the right lens". Very rough strawman to illustrate the concept:
// Pseudo-code, *not* a syntax proposal!
PointView {
"x" -> 0 : i32,
"y" -> 1 : i32,
}
let wasm_point = instance.exports.GetPoint();
console.log(wasm_point[0]);
let js_point = PointView(wasm_point);
console.log(js_point.x);
js_point.y = 42;
console.log(wasm_point[1]); // 42
(Regarding what the syntax for such definitions might be, I don't have strong feelings either way; what I know from our JavaScript folks is that in order to get fast startup, it would be desirable for that syntax to be as declarative as possible (and hence lazily/partially executable), as opposed to having to parse, compile, and execute a big chunk of single-invocation JavaScript code, which is a performance concern with the function makeTypes() {...}
approach in the existing text.)
Hi, great question and thanks for the thoughtful write-up! (I'll assume in your example that $InternalPoint
isn't meant to have the $color
field, so that it's structurally equivalent to $Point
and all your fields have mut
(b/c immutability is the default).)
One more-recent development is that, independent of both JS and wasm GC, we need roughly the same "nominal typing" mechanism in pure wasm in the form of abstract types that generate a fresh type upon each module instantiation. It's not clear what the precise final form will be, but this has been discussed briefly in the Type Imports proposal (#6 and #7). The motivation is allowing a module to encapsulate the representation of a type it exports so that it can, e.g., ensure no forged abstract type values. In the context of wasm abstract types, we have the same question that you're asking above. Once we have abstract types sorted out, we should probably refresh MVP-JS.md to, instead of introducing the problem as novel to JS, show how JS's nominal typing corresponds to wasm's abstract types.
Getting to your example problem, I think the answer is that, in the presence of nominal/generative types, the structural eq
constraint is not enough to ensure type equality, and thus the upcast cast from (ref $Rect)
to (ref $HasPoint)
would fail because the subtyping rules would require $HasPoint
's $point
field to be immutable. Changing $point
to be an immutable field would disallow the struct.set
in $Helper
.
OK, looks like when I added the $color
field for illustration I goofed up a few details. Here's an attempt to debug what I meant. Compared to my earlier post, all fields are now explicitly marked "mut" (which MVP-JS.md doesn't do either, fwiw), and $InternalPoint
and $PointSubclass
are split into two classes:
;; example4-modified.wat
(module
(type $Point (import "" "Point")
(eq (struct (field $x mut i32) (field $y mut i32))))
(type $Rect
(struct (field $x1 mut (ref $Point)) (field $x2 mut (ref $Point))))
(type $InternalPoint
(struct (field $x mut i32) (field $y mut i32)))
(type $PointSubclass
(struct (field $x mut i32) (field $y mut i32) (field $color mut f64)))
(type $HasPoint
(struct (field $point mut (ref $InternalPoint))))
(func $Helper (param (ref $HasPoint))
(struct.set $HasPoint $point
(get_local 0)
(struct.new $PointSubclass (i32.const 10) (i32.const 20) (f64.const 0.5))
)
(func (export "goWild") (param (ref $Rect))
(call $Helper (get_local 0))
)
)
My understanding of the GC proposal itself in its current form is that this code should work:
-
call $Helper
is valid because $Rect is a subtype of $HasPoint (by virtue of being structurally equivalent) -
struct.set
is valid because $PointSubclass is a subtype of $InternalPoint
So I think what it really boils down to is what you phrased as "the structural eq constraint is not enough to ensure type equality" -- to make the vision described in MVP-JS.md work, the imported type must somehow (either through explicit opt-in, or by default always) be non-interchangeable with structurally equivalent non-imported types. Which seems non-trivial to resolve with the general approach of a structural type system. I agree that if the Type Imports proposal end up solving the same problem, then that solution should be applicable here. Another option would be to move to a nominal type system everywhere (which has been discussed elsewhere, and comes with its own set of problems). Another option would be, as I alluded to earlier, to move the bridging of the nominal-JS/structural-Wasm gap to another point in the interaction API between the two, such that it doesn't impact the Wasm type system itself.
So I think what it really boils down to is what you phrased as "the structural eq constraint is not enough to ensure type equality" -- to make the vision described in MVP-JS.md work, the imported type must somehow (either through explicit opt-in, or by default always) be non-interchangeable with structurally equivalent non-imported types.
Correct, and so if the imported $Point
is non-interchangeable (not equal) with $InternalPoint
, then the call $Helper
will fail to validate because subtyping of mutable field requires type equality.
I also feel the pain of supporting both nominal and structural and it's something I've spent some time trying to find a way out of, but I'm not sure I see a better concrete option at this point. In some sense, I think this is unavoidable: wasm already has structural typing (especially considering the structural type-equality check in cross-instance call_indirect
) and yet given only structural typing, I don't think it's possible for a wasm module to encapsulate its representation or guarantee capability-safety.
especially considering the structural type-equality check in cross-instance
call_indirect
This is addressable in a (smoothly) backwards-compatible matter, so I would not let this concern outweigh very important things like representation-encapsulation or capability-safety.
Another thought: a key driver behind the plan to let Wasm have structural typing is to enable multi-module ecosystems, where modules can inter-operate as long as they define structurally compatible types, as opposed to having to agree on one canonical type definition. Doesn't the same apply to JavaScript?
Existing JavaScript has, let's call it a multi-faceted type system. Certainly, one could classify prototype identity as nominally-typed behavior. But a function like:
function foo(o) { return o.bar(); }
will happily operate on any object that has a bar
method -- on itself or anywhere on its prototype chain. (TypeScript's "interfaces" make this notion of duck-typing a very explicit concept, but it really exists all over JS. Given that it doesn't even care about order of fields, it is even less restrictive, or one could say that "less nominal", than Wasm-GC's proposed structural typing.)
So if we introduce strictly-nominal typing to JavaScript, then (aside from the question whether the JS community at large would approve of such a significant departure from traditional JS) that would run into the same issues as multi-module Wasm: if a JavaScript program wants to import several third-party libraries that operate on Rect
and/or Point
objects, then who gets to define the types that everyone else imports?
In summary, it seems to me that JavaScript Typed Objects and Wasm-GC should (one way or the other) likely end up with similar type systems, because many arguments that apply to the one also apply to the other. In that case, we likely wouldn't have to go through any contortions to deal with mismatches between the two worlds.
We have consensus on going with a "no-frills" approach to JS interop in the MVP (https://github.com/WebAssembly/gc/issues/279), so I don't think this issue is relevant anymore. Closing, but feel free to reopen if you disagree.