Ideas For A Javascript Stricter Mode
A long time ago Javascript introduced strict mode. This gave everyone an opportunity to fix some embarassing stuff in how Javascript worked, up to outright removing some functionality that was bad.
Things have of course improved a lot, especially with the advent of various transpiling efforts, more serious standardization workflows, and just a lot more eyes on Javascript as a whole by many more serious people. But we still have some issues that are hard to fix because of backwards compatibility concerns.
So.. what if we had one more chance? One more opportunity to fix it? What if we had "stricter mode"
?
Here are some ideas for what we can stick into stricter mode. Some of these ideas are, in my honest opinion, "clearly right". Some are merely the much better option. And some are extremely agressive changes that I would do if I were in charge of everything. But these should all at least get you thinking a bit. And all of these should be possible as a file-based opt-in, often via automated code rewriting.
Kill function scope and fix binding keywords
Function scope is great trivia about why you don't use var
. Great, we use let
. Also we don't want to accidentally rebind values, so we use const
. const
of course does not mean that a value is constant, but why would we believe that?
So lets say we get rid of function scope. var
no longer serves a purpose, right?
In my experience the vast majority of bindings do not get rebound. Best practice is to type const
. let
is 2 letters shorter. So we should use let
to mean "cannot rebind this name in the same scope".
No more confusion from const
sounding like its saying a value doesn't change (the pedants saying that its about the reference value being constant will be asked to provide a way to console.log
this supposed value that does not change).
But what about rebindable names? Names that might vary over time? var
is now freed for that purpose.
stricter-mode let
means what const
meant before. stricter-mode var
means what let
meant before.
And of course in this process we default function definitions to be non-rebindable once and for all.
Fix import keyword ordering
import { foo, bar } from "my-lib";
import { baz, cat } from "my-lib/internals";
current import statements suffer from two problems:
- a reader must jump to the end of a line to see what package something is coming from, then jump back to see what is being imported
- language tooling cannot help you type this because you specify what you are importing before what you import this from.
The single nice thing is that the keyword ordering matches how one would say things.
from "my-lib" import {foo, bar}
from "my-lib/internals" import {baz, cat}
We have now solved both problems.
One might wonder why I'm typing out import statements manually. To which I would reply that if you are using automated tooling to manage this, then you should be fine with either solution. I do not believe readability suffers from this, and it's an obvious win for tooling when you are typing all of this out.
Get rid of await
soup for most code
From the puppeteer docs, abridged:
async function visitPage() {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto("YOUR_SITE");
const three = await page.evaluate(() => {
return 1 + 2;
});
console.log(three);
await browser.close();
}
async
/await
has saved us from callback hell. And it was able to adapt an existing pattern of promises into a fully standardized concept and make transitioning to this kind of code wonderful.
But let's think about this a bit. If I am in an async
function, and I call another async
function, how often am I taking advantage of asynchronicity to do other work within that same function? How often am I instead just immediately await
-ing what I get back?
On top of that, when we combine fluent APIs with this, suddenly we get code that fails the "can be read from left to right" test:
const VIPs = (await getUsers()).filter(isVIP);
Typing this is often "type getUsers()
, await it, parens-wrap it, then add isVIP()
".
We of course want the ability to run several things at once and to coordinate things, but the vast majority of calls to async functions are immediately awaited, especially outside of library code.
Why should we have a keyword be present in 90+% of calls to a function? Well, it's explicit and makes sense! It reveals control flow as well.
But here's an alternative world:
async function visitPage() {
const browser = puppeteer.launch();
const page = browser.newPage();
page.goto('YOUR_SITE');
const three = page.evaluate(() => {
return 1 + 2;
});
console.log(three);
browser.close()
}
Here is the idea:
- You are inside a stricter-mode
async
function - You call another stricter-mode
async
function - The result will automatically be
await
-ed.
This expects the async
keyword to be used to tag functions returning promises. So any generic wrapping functions would lose this short of some shenanigans in the function wrapping. But the counter of this is that the behavior would not depend on the return value of a function, and would strictly be dependent on the function being called.
This sort of stuff plays well into linters and things like Typescript.
But how would you do anything concurrent? For the cases that you don't want automatic awaiting, you would instead access a new property of all stricter-mode async
functions: asPromise
.
const [browser1, browser2] = await Promise.all([
puppeteer.launch.asPromise(),
puppeteer.launch.asPromise(),
]);
This property would call the underlying function, but as it would not be marked for auto-await
ing, you could keep your code working.
Similarly Promise
functionality would not be marked for auto-await
ing, as its likely that if you are using those you are trying to do something involving flow control.
A lot of these are details but I believe the core idea is simple:
- strcter-mode
async
functions will be auto-await
ed - you can opt out of this with
asPromise
async
-ness will be a property of function signatures, so will have to be thought about
This is, of course, a major breakage and would be a hard thing for a linting tool to automatically apply.
The easier await
alternative
The less wild alternative would be to just make await
a postfix keyword.
let VIPs = getUsers().await.filter(isVIP)
Pretty easy, basically no semantics changes, and automation can rewrite all of this. If you have an await
property, you gotta write obj["await"]
. Feels pretty harmless.
Rust got this right, and you can have pipelining in your APIs without resorting to proxy objects that try to massage both objects and their promises to provide a cleaner top-level API.
Make object-returning arrow functions cleaner
The following is probably tricky without making parsers more complicated, but feels right.
let generatePerson =
name => {name, id: generateId()}
The arrow function returning a dictionary... except this doesn't work because the open bracket is parsed as a beginning of a block, and instead you need to do:
let generatePerson = (name) => {
return { name, id: generateId() };
};
considering how prevelant object literals are, this is not super fun!
I think that if we changed the language grammary to not allow the (nonsensical) "arrow function that includes just a single ientifier and does not return a value", we can get what we want here:
// current: function that returns void
// stricter-mode: returns {name: name}
let generatePerson = (name) => {
name;
};
We have the tools to make things better, so why not do it?