dop251 / goja

ECMAScript/JavaScript engine in pure Go

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Comparing primitive pointers

jmattheis opened this issue · comments

Goja will convert a go pointer of a primitive type to a JS object, and this prevents this value to be triple equaled in JavaScript. From my point of view this is counterintuitive because I'd expect that *uint16(nil) would be js(undefined or null) and a non nil pointer would be just the primitive value.

I could manually create the object via vm.NewObject(), but this would create boilerplate, which the https://github.com/dop251/goja#mapping-struct-field-and-method-names tries to prevent.

This issue is kinda a duplicate of #377, but I felt like it doesn't really describe the problems with this behavior. Feel free to close the issue regardless, as this probably makes the internal implementation of all primitive types more complex.

Simple example of the behavior:

type Thing struct {
	X uint16  `json:"x"`
	Y *uint16 `json:"y"`
}

func main() {
	vm := goja.New()

	vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
	x := uint16(5)
	vm.Set("thing", Thing{
		X: x,
		Y: &x,
	})

	fmt.Println("typeof thing.y  ", mustExec(vm, "typeof thing.y").String())
	fmt.Println("typeof thing.x  ", mustExec(vm, "typeof thing.x").String())

	fmt.Println("thing.x === 5  ", mustExec(vm, "thing.x === 5").ToBoolean())
	fmt.Println("thing.y === 5  ", mustExec(vm, "thing.y === 5").ToBoolean())
	fmt.Println("thing.x === thing.y  ", mustExec(vm, "thing.x === thing.y").ToBoolean())

	// typeof thing.y   object
	// typeof thing.x   number
	// thing.x === 5   true
	// thing.y === 5   false
	// thing.x === thing.y   false
}

func mustExec(vm *goja.Runtime, s string) goja.Value {
	v, err := vm.RunScript("myfile", s)
	if err != nil {
		panic(err)
	}
	return v
}

The thing is, there is no strict alignment between ECMAScript and Go, so whatever mapping mechanism is in place, someone is bound to be unhappy 😄 To me the current mapping is at least consistent, the pointer to a primitive is not a primitive so the strict equality operator does exactly what it is supposed to do, comparing pointers, e.g.:

	var i int
	vm := New()
	vm.Set("i", &i)
	vm.Set("i1", &i)
	res, err := vm.RunString("i === i1")
	if err != nil {
		t.Fatal(err)
	}
	if res.Export() != true {
		t.Fatal(res)
	}

The converted value is a host object that behaves similarly to new Number() with one notable difference that its primitive value is mutable.

Changing ToValue() so that it implicitly dereferences the pointer would be wrong in my view. Changing the resulting host value so that it looks exactly like a primitive would also be wrong, mostly because primitive values in ECMAScript are immutable.

As I hinted in #377, it's possible to implement your own version of ToValue() and use that instead. It's a bit involved process because you'd need to iterate over Object's keys and array items, but it's doable. Alternatively you can use DynamicObject and DynamicArray which gives full control.

Having said that, I'm open to suggestions on how to make ToValue() customisable as long as it doesn't break compatibility and doesn't impact performance.

I understand why goja functions this way, but using Number, String, and Boolean in JavaScript is generally a bad practice because they cannot be triple equaled.

I've two ideas for the public API:

  1. Have a JSON compatibility mode, meaning a nil pointer will be undefined if json:",omitempty" isset or null otherwise. This would be my favorite solution, because I'd expect passing values to goja would be similar to passing JSON to the runtime. Maybe this could be added to the field name mapper that it also allows mapping the values.

  2. Adding custom tags to the struct maybe something like MyField *uint16 goja:"no-primitive", but dunno exactly how to call this.

It only works for structs, what about map values or just standalone variables?

We could do something like a field value mapper, in there we could have a method func(value any) goja.Value and this is called with every primitive value and every primitive pointer value. Then it should be fairly easy to change the default to instantiate an object for primitive pointers or use undefined / null.