Repository navigation
RFC: New syntax for fixed external arguments (replacing @as(...) _) #8878
Description
Summary
External bindings sometimes need an argument that is always the same value and that callers never pass, e.g. document.createElement("img") or Object.assign({}, ...). Today such fixed arguments are written as an @as(...) attribute on a _ type:
@send external createImg: (document, @as("img") _) => element = "createElement"
@val external assign: (@as(json`{}`) _, t, {..}) => t = "Object.assign"This RFC proposes a dedicated syntax for them and presents four options:
%raw("..."): the value as JavaScript source.=value/~label=value: the value as a ReScript literal, in "default argument" style._ = value/~label: _ = value: the value as a ReScript literal, after the_that marks the argument as not passed by callers.fixed value/~label: fixed value: the value as a ReScript literal, after a contextualfixedkeyword.
These are starting points, not a closed list: better proposals are very welcome.
Motivation
The current syntax has several problems:
- The meaning is hidden in an attribute.
@as(...)on_turns a type into a value, which is hard to discover and unlike anything else in the language.@asalso means something quite different on record fields and variant constructors. - Anything beyond strings and integers needs the special
jsontag, e.g.@as(json`{}`)for an empty object,@as(json`null`)or@as(json`false`). This madejsontemplate literals a special case in the compiler. - The value is easy to misread as a type, and the formatter and editor tooling see an attribute rather than a value.
The compiler side is being prepared independently of the syntax: #8875 gives fixed arguments their own parsetree node and removes the special json constant. #8876 adds option 1 as a provisional syntax. Whichever option this RFC settles on, it is a parser and printer change on top of that node.
Options
The examples bind document.createElement("img"), Object.assign({}, ...), history.replaceState(null, "", url) and an @obj option that is always false.
Option 1: %raw(...)
@send external createImg: (document, %raw(`"img"`)) => element = "createElement"
@val external assign: (%raw("{}"), t, {..}) => t = "Object.assign"
@val external replaceState: (%raw("null"), %raw(`""`), string) => unit = "history.replaceState"
@obj external makeOptions: (~objectMode: %raw("false"), ~name: string, unit) => options = ""The payload is JavaScript source and must be exactly one JavaScript literal.
- Pros
- Reuses an existing construct; no new grammar.
- Clearly an escape hatch, consistent with
%rawelsewhere. - Can express any JavaScript literal, including regexps and bigints.
- Cons
- Strings need two levels of quoting (
%raw(`"img"`)). - The value is a string of JavaScript, so it gets no highlighting or formatting, and mistakes are reported late.
- Encourages raw JavaScript for what are almost always plain constants.
- Strings need two levels of quoting (
Option 2: =value and ~label=value
@send external createImg: (document, ="img") => element = "createElement"
@val external assign: (={}, t, {..}) => t = "Object.assign"
@val external replaceState: (=null, ="", string) => unit = "history.replaceState"
@obj external makeOptions: (~objectMode=false, ~name: string, unit) => options = ""The value is a ReScript literal: string, number, boolean, null, undefined, array, object or record.
- Pros
- The labelled form mirrors the call site (
~objectMode=false). - Values are ordinary ReScript literals: no double quoting, and the compiler produces the JavaScript.
- The formatter and editor tooling treat the value like any other expression.
- The labelled form mirrors the call site (
- Cons
~x=valueis exactly the syntax of a default argument in function definitions, where the caller may still pass a different value. Here the caller cannot pass it at all.- The unlabelled form
="img"looks unusual. ~x=valueand~x: t=?side by side can be confusing.- Can't express JavaScript-only literals such as regexps.
Option 3: _ = value and ~label: _ = value
@send external createImg: (document, _ = "img") => element = "createElement"
@val external assign: (_ = {}, t, {..}) => t = "Object.assign"
@val external replaceState: (_ = null, _ = "", string) => unit = "history.replaceState"
@obj external makeOptions: (~objectMode: _ = false, ~name: string, unit) => options = ""The values are ReScript literals, as in option 2.
- Pros
- The closest to today's syntax (
@as(x) _becomes_ = x). It keeps the_, which already says "the caller does not pass this". - The same shape for unlabelled and labelled arguments.
- Values are ordinary ReScript literals, with the same benefits as option 2.
- The closest to today's syntax (
- Cons
- Slightly more verbose than option 2.
~x: _ = valueresembles a typed default argument (~x: int=1) in function definitions.- Can't express JavaScript-only literals such as regexps.
Option 4: fixed value and ~label: fixed value
@send external createImg: (document, fixed "img") => element = "createElement"
@val external assign: (fixed {}, t, {..}) => t = "Object.assign"
@val external replaceState: (fixed null, fixed "", string) => unit = "history.replaceState"
@obj external makeOptions: (~objectMode: fixed false, ~name: string, unit) => options = ""The values are ReScript literals, as in options 2 and 3. fixed is a contextual keyword: it only has this meaning when a value follows it, which is never valid type syntax today, so types, record fields and variables named fixed keep working.
- Pros
- Says what it means:
fixed {}reads as "this argument is always{}", with no resemblance to default arguments. - The same shape for unlabelled and labelled arguments, and reads well in both.
- Values are ordinary ReScript literals, with the same benefits as option 2.
- Easy to search for and to document.
- Says what it means:
- Cons
- Adds a (contextual) keyword to the language.
- Editor syntax highlighting needs to learn it.
- Can't express JavaScript-only literals such as regexps.
Details common to all options
- Where they're allowed. Fixed arguments are only allowed as direct arguments of an
external, inside the parenthesized argument list, soexternal f: _ = "f"keeps its current meaning. - Optional arguments. A fixed argument can't be optional, because it is always passed.
- Values in options 2 to 4. The value is read as written and isn't type-checked:
{}means an empty JavaScript object, andnull/undefinedmean the JavaScript values. - Migration.
rescript formatrewrites the existing@as(...) _forms to the new syntax automatically. Until then they keep working with a deprecation warning, and they will be removed in a later major version. The stdlib and Belt would be migrated in the same release. - PPX compatibility. PPXs keep seeing fixed arguments in the existing form,
_ [@as ...], so they aren't affected.
Feasibility
All four options have been implemented or prototyped in the parser. Option 1 is in #8876. Options 2 to 4 parse without conflicting with existing syntax, compile to the expected JavaScript, and leave the existing parser and printer test suites unchanged. For option 4, existing code using fixed as a type, field or variable name is unaffected.
Question
Which option do you prefer, and why? Or do you have a better idea?
Activity