A syntactical simplification in JS to enable DSLs
Clone or download
Sam Goto
Sam Goto updating slides
Latest commit 9328467 Nov 30, 2017

README.md

Early feedback from @adamk, @domenic, @slightlyoff, @erights, @waldemarhowart, @bterlson and @rwaldron (click here to send feedback).

Block Params

This is a very early stage 0 exploration of a syntactical simplication (heavily inspired by Kotlin, Ruby and Groovy) that enables domain specific languages to be developed in userland.

It is a syntactic simplification that allows, on function calls, to omit parantheses around the last parameter when that's a lambda.

For example:

// ... this is what you write ...
a(1) {
  // ...
}

// ... this is what you get ...
a(1, () => {
  // ...
})

Functions that take just a single block parameter can also be called parentheses-less:

// ... this is what you write ...
a {
  // ...
}

// ... this is what you get ...
a(() => {
  // ...
})

We want to enable the ability to nest block params (e.g. to enable paired block params like select/when, builders and layout), and we are currently exploring using a sygil (e.g. possibly consistent with the bind operator ::) to refer to the parent block param:

// ... this is what you write ...
a(1) {
  ::b(2) {
  }
}

// ... this is somewhat (with some TBD symbol magic) you get ...
a (1, (__parent__) => {
  __parent__.b(2, (__parent__) => {
  })
})

Arguments can be passed to the block param:

// ... this is what you write ...
a(1) do (foo) { // syntax TBD
  // ...
}

// ... this is what you get ...
a(1, (foo) => {
  ...
})

To preserve Tennent's Corresponde Principle, we are exploring which restrictions apply inside the block param (e.g. because these are based on arrow functions, break and continue aren't available as top level constructs and return may behave differently).

While a simple syntactical simplification, it enables an interesting set of userland frameworks to be built, taking off presure from TC39 to design them (and an extensible shadowing mechanism that enables to bake them natively when/if time comes):

Here are some interesting scenarios:

And interesting applications in DOM construction:

This is early, so there are still a lot of areas to explore (e.g. continue and break, return, bindings and this) as well as strategic problems to overcome (e.g. forward compatibility) and things to check feasibility (e.g. completion values).

There is a polyfill, but I wouldn't say it is a great one quite yet :)

It is probably constructive to start reading from the prior art section.

Use cases

A random list of possibilities collected from kotlin/groovy (links to equivalent idea in kotlin/groovy at the headers), somewhat sorted by most to least compelling.

flow control

lock

lock (resource) {
  resource.kill();
}

Perl's unless

unless (expr) {
  // statements
}

Swift's guard

assert (document.cookie) {
  alert("blargh, you are not signed in!");
}

Swift's defer

defer (100) {
  // internally calls setTimeout(100)
  alert("hello world");
}

C#'s foreach

// works on arrays, maps and streams
foreach (array) do (item) {
  console.log(item);
}

VB's select

let a = select (foo) {
  ::when (bar) { 1 }
  ::when (hello) { 2 }
  ::otherwise { 3 }
}

C#'s using

using (stream) {
  // stream gets closed automatically.
}

builders

maps

// ... and sets ...
let a = map {
  ::put("hello", "world") {}
  ::put("foo", "bar") {}
}

dot

let a = graph("architecture") {
  ::edge("a", "b") {}
  ::edge("b", "c") {}
  // ...
}

custom data

let data = survey("TC39 Meeting Schedule") {
  ::question("Where should we host the European meeting?") {
    ::option("Paris")
    ::option("Barcelona")
    ::option("London")
  }
}

layout

kotlin's templates

let body = html {
  ::head {
    ::title("Hello World!") {}
  }
  ::body {
    ::div {
      ::span("Welcome to my Blog!") {}
    }
    for (page of ["contact", "guestbook"]) {
      ::a({href: `${page}.html`}) { span(`${page}`) } {}
    }
  }
}

android

let layout =
  VerticalLayout {
      ::ImageView ({width: matchParent}) {
        ::padding = dip(20)
        ::margin = dip(15)
      }
      ::Button("Tap to Like") {
        ::onclick { toast("Thanks for the love!") }
      }
    }
  }

Configuration

node

const express = require("express");
const app = express();

server (app) {
  ::get("/") do (response) {
    response.send("hello world" + request().get("param1"));
  }

  ::listen(3000) {
    console.log("hello world");
  }
}

makefiles

job('PROJ-unit-tests') {
  ::scm {
      ::git(gitUrl) {}
  }
  ::triggers {
      ::scm('*/15 * * * *') {}
  }
  ::steps {
      ::maven('-e clean test') {}
  }
}

Misc

regexes

// NOTE(goto): inspired by https://github.com/MaxArt2501/re-build too.
let re = regex {
  ::start()
  ::then("a")
  ::then(2, "letters")
  ::maybe("#")
  ::oneof("a", "b")
  ::between([2, 4], "a")
  ::insensitively()
  ::end()
}

graphql

// NOTE(goto): hero uses proxies/getters to know when properties
// are requested. depending on the semantics of this proposal
// this may not be possible to cover.
let heroes = hero {
  ::name
  ::height
  ::mass
  ::friends {
    ::name
    ::home {
      ::name
      ::climate
    }
  }
}

testing

// mocha
describe("a calculator") {

  val calculator = Calculator()

  ::on("calling sum with two numbers") {

    val sum = calculator.sum(2, 3)

    ::it("should return the sum of the two numbers") {

      shouldEqual(5, sum)
    }
  }
}

Applications

One of the most interesting aspects of this proposal is that it opens the door to statement-like structures inside expressions, which are most notably useful in constructing the DOM.

Template Literals

For example, instead of:

let html = `<div>`;
for (let product of ["apple", "oranges"]) {
  html += `<span>${product}</span>`;
}
html += `</div>`;

or

let html = `
  <div>
  ${["apple", "oranges"].forEach(product => `<span>${product}</span>`)}
  </div>
`;

One could write:

let html = `
  <div>
  ${foreach (["apple", "orange"]) {
    `<span>${item()}</span>`
  }}
  </div>
`;

JSX

For example, instead of:

// JSX
var box =
  <Box>
    {
      shouldShowAnswer(user) ?
      <Answer value={false}>no</Answer> :
      <Box.Comment>
         Text Content
      </Box.Comment>
    }
  </Box>;

One could write:

// JSX
var box =
  <Box>
    {
      select (shouldShowAnswer(user)) {
        ::when (true) {
          <Answer value={false}>no</Answer>
        }
        ::when (false) {
          <Box.Comment>
             Text Content
          </Box.Comment>
        }
      }
    }
  </Box>;

Extensions

This can open a stream of future extensions that would enable further constructs to be added. Here are some that occurred to us while developing this.

These are listed here as extensions because I believe we don't corner ourselves by shipping without them (i.e. they can be sequenced independently).

chaining

From @erights:

To enable something like

if (arg1) {
  ...
} else if (arg2) {
  ...
} else {
  ...
}

You'd have to chain the various things together. @erights proposed something along the lines of making the chains be passed as parameters to the first function. So, that would transpile to something like

if (arg1, function() {
  ...
},
"else if", arg2, function {
  ...
},
"else", function () {
  ...
})

Another notable example may be to enable try { ... } catch (e) { ... } finally { ... }

functization

From @erights:

To enable control structures that repeat over the lambda (e.g. for-loops), we would need to re-execute the stop condition. Something along the lines of:

let i = 0;
until (i == 10) {
  ...
  i++
}

We would want to turn expr into a function that evaluates expr so that it could be re-evaluated multiple times. For example

let i = 0;
until (() => i == 10, function() {
  ...
  i++
})

TODO(goto): should we do that by default with all parameters?

Areas of Exploration

These are some areas that we are still exploring.

Tennent's Correspondence Principle

To preserve tennent's correspondence principle as much as possible, here are some considerations as we decide what can go into block params:

  • return statements inside the block should either throw SyntaxError (e.g. kotlin) or jump to a non-local return (e.g. kotlin's inline functions non-local returns)
  • break, continue should either throw SyntaxError or control the lexical flow
  • yield can't be used as top level statements (same strategy as () => { ... })
  • throw works (e.g. can be re-thrown from function that takes the block param)
  • the completion values are used to return values from the block param (strategy borrowed from kotlin)
  • as opposed to arrow functions, this can be bound.

Forward Compatibility

If we bake this in, do we corner ourselves from ever exposing new control structures (e.g. unless () {})?

That's a good question, and we are still evaluating what the answer should be. Here are a few ideas that have been thrown around:

  • user defined form shadows built-in ones
  • sigils (e.g. for! {})

In this formulation, we are leaning towards the former.

It is important to note that the current built-in ones can't be shadowed because they are reserved keywords. So, you can't override for or if or while (which I think is working as intended), but you could override ones that are not reserved keywords (e.g. until or match).

Completion Values

Like Kotlin, it is desirable to make the block params return values to the original function calling them. We aren't entirely sure yet what this looks like, but it will most probably borrow the same semantics we end up using in do expressions and other statement-like expressions.

let result = foreach (numbers) do (number) {
  number * 2 // gets returned to foreach
}

scoping

There are certain block params that go together and they need to be somehow aware of each other. For example, select and when would ideally be described like this:

select (foo) {
  when (bar) {
    ...
  }
}

How does when get resolved?

The global scope? If so, how does it connect with select to test bar with foo?

From select? If so, how does it avoid using the this reference and have with-like performance implications? perhaps @@this?

return

From @bterlson:

It would be great if we could make return to return from the lexically enclosing function.

Kotlin allows return from inlined functions, so maybe semantically there is a way out here.

One challenge with return is for block params that outlive the outer scope. For example:

function foobar() {
  run (100) {
    // calls setTimeout(1, block) internally
    return 1;
  }
  return 2;
}
foobar() // returns 2
// after 100 ms
// block() returns 1. does that get ignored?

Note that Java throws a TransferException when that happens. SmallTalk allows that too, so the intuition is that this is solvable.

continue, break

continue and break are interesting because their interpretation can be defined by the user. For example:

for (let i = 0; i < 10; i++) {
  unless (i == 5) {
    // You'd expect the continue to apply to the
    // lexical for, not to the unless
    continue;
  }
}

Whereas:

for (let i = 0; i < 10; i++) {
  foreach (array) do (item) {
    if (item == 5) {
      // You'd expect the continue here to apply to
      // the foreach, not the lexical for.
      continue;
    }
  }
}

It is still unclear if this can be left as an extension without cornering ourselves.

We are exploring other alternatives here.

bindings

From @bterlson:

There are a variety of cases where binding helps. For example, we would want to enable something like the following:

foreach (map) do (key, value) { ... } to be given by the foreach function implementation.

foreach (map) do (key, value) {
  // ...
}

To be equivalent to:

// ... is equivalent to ...
foreach (map, function(key, value) {
})

Exactly which keyword we pick (e.g. in or with or : etc) and its position (e.g. foreach (item in array) or foreach (array with item)) TBD.

Another alternative syntax could be something along the lines of:

foreach (map) { |key, value|
  // ...
}

Or

foreach (let {key, value} in map) {
  // ...
}

We probably need to do a better job at exploring the design space of use cases before debating syntax, hence leaving this as a future extension.

Polyfill

This is currently polyfilled as a transpiler. You can find a lot of examples here.

npm install -g @docscript/docscript

Tests

npm test

Status

You really don't want to use this right now. Very early prototype.

Prior Art

The following is a list of previous discussions at TC39 and related support in other languages.

TC39

SmallTalk

Ruby

def iffy(condition) 
  if (condition) then
    yield()
  end
end 

iffy (true) {
  puts "This gets executed!"
}
iffy (false) {
  puts "This does not"
}
for i in 0..1 
  puts "Running: #{i}"
  iffy (i == 0) {
    # This does not break from the outer loop!
    # Prints
    #
    # Running: 0 
    # Running: 1
    break
  }
end


for i in 0..1 
  iffy (i == 0) {
    # This does not continue from the outer loop!
    # Prints
    #
    # Running: 0 
    # Running: 1
    next
  }
  puts "Running: #{i}"
end

def foo() 
  iffy (false) {
    return "never executed"
  }
  iffy (true) {
    return "executed!"
  }
  return "blargh, never got here!"
end

# Prints "executed!"
foo()

Groovy

Kotlin

fun main(args: Array<String>) {
    unless (false) {
      println("foo bar");
      "hello"  // "this expression is unused"
      "world" // "this expression is unused"
      1  // last expression statement is used as return value
        
      // "return is not allowed here"
      // return "hello"
      // 
      // "break and continue are only allowed inside a loop"
      // continue;
      // 
      // throwing is allowed.
      // throw IllegalArgumentException("hello world"); 
    };
    
    var foo = "hello";
    
    switch (foo) {
        case ("hello") {
            
        } 
        case ("world") {
            
        }
    }
}

fun unless(expr: Boolean, block: () -> Any) {
    if (!expr) {
      var bar = block();
      println("Got: ${bar}") 
    }
}

fun switch(expr: Any, block: Select.() -> Any) {
    var structure = Select(expr);
    structure.block();
}

fun case() {
    println("hi from global case");
}

class Select constructor (head: Any) {
    var result = null;
    fun case(expr: Any, block: () -> Any) {
        if (this.head == expr) {
          println("hi from case");
          result = block();
        }
    }
}

Java

 for eachEntry(String name, Integer value : map) {
  if ("end".equals(name)) break;
  if (name.startsWith("com.sun.")) continue;
  System.out.println(name + ":" + value);
 }

Related Work