Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

JS API and nominal/structural typing #84

Closed
jakobkummerow opened this issue Mar 24, 2020 · 6 comments
Closed

JS API and nominal/structural typing #84

jakobkummerow opened this issue Mar 24, 2020 · 6 comments

Comments

@jakobkummerow
Copy link
Contributor

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.)

@lukewagner
Copy link
Member

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.

@jakobkummerow
Copy link
Contributor Author

jakobkummerow commented Mar 26, 2020

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.

@lukewagner
Copy link
Member

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.

@RossTate
Copy link
Contributor

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.

@jakobkummerow
Copy link
Contributor Author

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.

rossberg added a commit that referenced this issue Feb 24, 2021
* Remove passive segment func ref shorthand

* Drop passive keyword
rossberg pushed a commit that referenced this issue Feb 24, 2021
Change the test generators to use `ref.func` and remove `passive`.

At some point we'll want to remove the generators, but for let's try to
maintain them.
@tlively
Copy link
Member

tlively commented Nov 1, 2022

We have consensus on going with a "no-frills" approach to JS interop in the MVP (#279), so I don't think this issue is relevant anymore. Closing, but feel free to reopen if you disagree.

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

No branches or pull requests

4 participants