Advanced JavaScript interop

Amber and JavaScript gives the rules that every day needs. This page gives the rest. It is for the moment a library’s object does not answer as that page says it should. Every example on this page was run in an image of Amber 0.32.0 under Node.js.

The proxy’s rules in full

A unary message whose name starts with a capital letter reads the property and does not call it. That is how a constructor is read. Platform globals Map is the function Map, uncalled, and new makes a Map from it. A unary message in lower case calls a function property. Platform globals parseInt calls parseInt() with no arguments and answers NaN.

A keyword message with two or more arguments must name a function. On a plain property it raises an error. The error says “Cannot interpret a with 2 arguments; field is a number, not a function”.

The proxy answers its own protocol before it looks at the object. That protocol is the one of ProtoObject: class, isNil, ifNil:, yourself, printString, inspect, perform: and isKindOf:. The proxy adds its own, at: and at:put: among them. So (JSON parse: '{"isNil": true}') isNil is false, and printString of any proxy is “[object Object]”. A property of such a name is read with at:. (JSON parse: '{"isNil": true}') at: 'isNil' is true.

at: reads a property and at:put: writes one, whatever the property holds. at:ifAbsent: answers the block’s value when the object has no property of that name. removeKey: deletes the property. in: evaluates a block with the object itself, and jsObject answers it.

Two proxies are equal, =, when they wrap the same object. == is never true for a JavaScript object, not even with itself. Every send wraps the object in a new proxy, and == compares the proxies. With o := JSON parse: '{}', o == o is false and o = o is true. So JavaScript objects are compared with =.

Smalltalk readJSObject: turns a plain object into a Dictionary and an array into an Array, through every level. Everything else it leaves as it is. Smalltalk readJSObject: (JSON parse: '{"a": [1, {"b": 2}]}') is a Dictionary whose a is an Array of 1 and a Dictionary. It is the way to take data in as Amber collections.

A function used as an object

A function is a block, and a block understands Amber’s protocol alone. f := Platform globals at: 'parseInt' is a block. f value: '42' is 42, and f length is not understood. For a send or two, a proxy on the function reaches its properties. (JSObjectProxy on: f) length is 2, and name is ‘parseInt’.

A function that is used as an object more than called is opted out of Smalltalk instead:

Smalltalk optOut: f

From then on every send to f goes through a proxy. f length is 2 and f name is ‘parseInt’. f value: '42' is no longer understood, because a proxy calls a function only as a property of another object. NativeFunction functionOf: f value: '42' calls it, and answers 42. Smalltalk cancelOptOut: f makes f a block again, and f value: '42' is 42.

The mark is an own property of the object, a$cls with the value null. It shadows the property that says which Amber class an object has. JSON stringify: shows it, and so does a for in loop.

Mithril’s m is such a case. m makes a virtual node and is called for every node, so it stays a block. m.route is called once and then used for its prefix, get and set, and m.redraw for its sync. The Mithril variant opts the two out once, when its class is initialized.

optOut: takes any object, an Amber one too. p := 1@2. Smalltalk optOut: p makes a plain object of the point. p x reads the property and answers 1, p x: 5 writes it, and p + (1@1) is not understood. Smalltalk cancelOptOut: p makes it a Point again.

Calling a function by hand

NativeFunction calls a function the way JavaScript would. Each of its three families takes the function, or the name of a global function. The arguments follow, up to three, or an array with valueWithArgs:.

The third is for a method taken off its object:

| map get |
map := Map new.
map set: 'a' value: 1.
get := map at: 'get'.
get value: 'a'.                                       "Method Map.prototype.get called on incompatible receiver undefined"
NativeFunction methodOf: get this: map value: 'a'.    "1"
map get: 'a'.                                         "1"

map at: 'get' is the function Map.prototype.get on its own, and a block. Called with value:, it has no this, and fails. methodOf:this: gives it the map. map get: 'a' does the same through the proxy, which calls the function on its object.

NativeFunction isNativeFunction: is true for any function, a block included. NativeFunction exists: says whether a global of that name exists.

What goes out

An Amber object goes to JavaScript as it is, and most are JavaScript’s own values already. asJavaScriptObject is for the ones that JavaScript takes in another form. What it answers depends on the class:

Receiver asJavaScriptObject
a string, a number, a boolean, a block itself
nil null
a proxy the object it wraps
an Array, an OrderedCollection, a Set a new array, each element converted
a Dictionary, a HashedCollection a new plain object, each value converted
any other object a new plain object with the receiver’s slots as properties, each converted, and a warning in the console

The conversion goes through every level. { 1. nil. 'a' } asJavaScriptObject is [1, null, "a"]. The array as it is holds Amber’s nil, which JSON stringify: shows as {}. A Dictionary as it is is an object with keys and values, and converted it is {"k": 1}. asJSONString is JSON stringify: of the converted value.

The last row is the default of Object, and it is deprecated. The warning says to implement asJavaScriptObject on your own class, in the form JavaScript must read. (1@2) asJavaScriptObject is {"x": 1, "y": 2}, with the warning. Four kernel classes whose objects are JavaScript’s own have no conversion of their own: Date, Error, Promise and RegularExpression. A Date has no slots, so the default turns it into an empty object. A Promise becomes an object that is no promise. Pass them as they are. A Date is a JavaScript Date already.

asJavaScriptSource answers a value as JavaScript source. #(1 $a 'b') asJavaScriptSource is [(1), "a", "b"], and nil asJavaScriptSource is null. The compiler uses it for literals.