Closed Chriscbr closed 1 month ago
Thanks for opening this pull request! :tada: Please consult the contributing guidelines for details on how to contribute to this project. If you need any assistance, don't hesitate to ping the relevant owner over Discord.
Topic | Owner |
---|---|
Wing SDK and standard library | @chriscbr |
Wing Console | @skyrpex |
Wing compiler and language design | @chriscbr |
VSCode extension and language server | @chriscbr |
Wing CLI | @chriscbr |
Documentation | @boyney123 |
Examples | @boyney123 |
Wing Playground | @skyrpex |
It looks like we need to be smart about how we emit these reflection classes, otherwise we'll run into code generation issues. For example, supposing you have structs that refer to each other like these:
struct S1 {
child: S2?;
}
struct S2 {
child: S1?;
}
Then the naive way of generating meta-objects in JavaScript will look something like this:
new std.StructType("S1", {
child: new std.OptionalType(
new std.StructType("S2", {
child: new std.OptionalType(
new std.StructType("S1", <infinity>)
),
}),
),
});
The example above is contrived, but in practice there are many recursive relationships between classes in Wing libraries and the SDK via method parameters and return types etc.
The way I'm thinking of solving this is to "collect" the list of types referenced through helper functions in jsify.rs
, assigning names to each of them, and then to emit somewhere around the beginning of preflight.js looking something like:
// prelude: we instantiate classes for all types, without their contents filled
let $s1_type = new std.StructType("S1");
let $s2_type = new std.StructType("S2");
// after: emit code to fill in type information, with cycles allowed
$s1_type.fields = { child: new std.OptionalType($s2_type) };
$s2_type.fields = { child: new std.OptionalType($s1_type) };
This code that defines reflection data for named types (classes, structs, interfaces, enums) can go into some kind of prelude section of preflight.js
. Or even in a separate file. Then, wherever @type(MyStruct)
is actually called, it will just emit a reference to the variable $s1_type
.
I've been making a little bit more progress. To do the approach above, we basically need to do some recursion on types. When we generate the javascript reflection code for S1, it needs to emit the reflection code for S2, and to emit that reflection code, we'd need to emit reflection code for S1. To avoid recursing indefinitely, we need to have a base case where if a type has already been explored (we've generated code for it), then we stop and reuse the existing result.
But we can't do that easily today because Type
/ TypeRef
do not implement the Eq/Hash traits. Put another way, we have no way to tell if two types are equal. Part of the reason why it hasn't been easy to define equality on types is because we create duplicate type objects even for identical function signatures - there's no interning process going on. It's possible we can add a stub implementation of Eq/Hash that handles enough of the cases and works around this, but it might be worthwhile to see if we can change how we're storing types to avoid.
Wow - this recursion problem really punches back!
So it seems like I fixed all of the issues with generating types that may have cyclic references in preflight. I've copied an example of a types.cjs
file generated with my local build:
All of this works fine. We define the types first, then fill in the types' contents.
But it appears that separately, there's a problem with lifting std.Type
instances from preflight to inflight. Namely, calling toInflight()
sometimes blows the stack frame.
Let's look back at the example before:
struct S1 {
child: S2?;
}
struct S2 {
child: S1?;
}
And suppose we lift a type reflection:
let s1 = @type(S1);
inflight () => {
log(s1.kind); // should print "struct"
}
In preflight, s1
is an instance of std.Type
storing inside a std.StructType
. To use it inflight, we have to generate some JS code that will get included in the inflight function, through toInflight()
. What does the toInflight()
implementation look like? The current naive implementation looks approximately like this:
/** @internal */
public _toInflight(): string {
const args = [
`"${this.name}"`,
this.fqn ? `"${this.fqn}"` : "undefined",
arrayToInflight(this.bases), // helper function that calls _toInflight() on each parent struct reflection
mapToInflight(this.fields), // helper function that calls _toInflight() on each field reflection
];
return `new std.StructType(${args.join(", ")})`;
}
But as you can imagine, this means s1._toInflight()
will end up calling s2._toInflight()
which will then call s1._toInflight()
and so on.
The solution isn't as obvious to me. One idea could be to do some kind of runtime tree traversal -- so we can find all unique types -- then define some variables for them, then call an altered method like toInflightWithContext()
on each type so that it can refer to this map of variables (stopping the endless recursion) when it sees another type.
Another idea could be to totally revise how we're code generating these helper classes. But I'm not sure where I'd start with that to be honest.
Another idea could be to totally revise how we're code generating these helper classes. But I'm not sure where I'd start with that to be honest.
A sketch of a thought: instead of only generating types that are accessed, perhaps we could generate a single in-memory data structure that includes the entire app type system. Basically a big-ass JSON dump of the application's type info that the compiler holds in-memory. Then @type(x)
will just access this at runtime (preflight-time).
So this:
class Foo {}
let x = @type(Foo);
Becomes this:
const $types = require("./types.json");
const x = std.Type._of($types["fqn.of.Foo"]);
instead of only generating types that are accessed, perhaps we could generate a single in-memory data structure that includes the entire app type system.
Depending on how this is done, this could be a huge performance hit with JSII. Unless the original jsii manifest could be reused somehow.
But it appears that separately, there's a problem with lifting std.Type instances from preflight to inflight. Namely, calling toInflight() sometimes blows the stack frame. But as you can imagine, this means s1._toInflight() will end up calling s2._toInflight() which will then call s1._toInflight() and so on.
@Chriscbr Maybe this is too far, but what if the std.Type
API were changed so that all access to child type information is done through a preflight method instead of a property? iirc this is how the Type class in C# reflection works https://learn.microsoft.com/en-us/dotnet/api/system.type?view=net-8.0 (perhaps due to similar recursion problems?)
There's a hit to ergonomics, but I think it avoids the eager/cyclic lifting
struct S1 {
child: S2?;
}
struct S2 {
child: S1?;
}
let s1 = @type(S1);
let s2 = s1.getField("child");
let allFields = s1.getFields();
inflight () => {
log(s1.kind); // should print "struct"
log(s2.kind); // should print "struct"
}
Console preview environment is available at https://wing-console-pr-7151.fly.dev :rocket:
Thanks for contributing, @Chriscbr! This PR will now be added to the merge queue, or immediately merged if rybickic/type-intrinsic
is up-to-date with main
and the queue is empty.
Congrats! :rocket: This was released in Wing 0.85.18.
Closes #7150
Adds for the new
@type
intrinsic function. This function can be passed a type, and it will give you back an object (std.reflect.Type
) with information about it -- for example, whether it's a struct or class, what properties or fields it has, etc. You can use this to validate request data structures at runtime, generate schemas for APIs or databases, or for simply asserting on the structure of your code.Future work:
TypeId
enumChecklist
pr/e2e-full
label if this feature requires end-to-end testingBy submitting this pull request, I confirm that my contribution is made under the terms of the Wing Cloud Contribution License.