⚡️ LightningScript

A vision for the future of TypeScript / Compile-to-JavaScript languages.

Why?#

JavaScript is a great but flawed language. For decades now, the JavaScript community has been trying to fix these flaws in many different ways.

On the one hand we have the standards process and tools like TypeScript that maintain backwards compatibility while adding new features. While this undoubtedly has its benefits, being unable to remove old features and syntax means that the language will always be held back by its past.

On the other hand, we have new languages like Elm, ReasonML, and ClojureScript that are able to make more radical changes to the language and its ecosystem. However, these languages can cause fragmentation as they are often not compatible with existing JavaScript code or require manual translation layers. The radical changes also mean that many JavaScript developers are unwilling to make the switch.

LightningScript is a vision for a language that combines the best of both worlds. A language that is instantly familiar to JavaScript developers, maintains the semantics of JavaScript, and is fully compatible with existing JavaScript code. At the same time, it is a language that is able to make changes to improve the developer experience and remove many of the flaws of JavaScript.

LightningScript is also a vision for a new ecosystem of tools and libraries that can work with or without using the new proposed syntax. The core goal of this vision is to establish a set of conventions for tools to reduce the amount of custom configuration required to build projects for the web.

What?#

NOTE: LightningScript is "Vision Document" and not a formal specification or implementation. Think of this as a proposal to the larger JavaScript community.

We want to start a convention and form consensus before building anything new and causing proliferation.

How Standards Proliferate

Goals#

New Features#

We propose adding a few new features to JavaScript. Most of these are already TC39 proposals, but we are choosing to make syntactic changes to make them more ergonomic without making it feel unfamiliar.

if Expressions#

TC39 Proposal: Do expressions

If statements are useful but they are statements and not expressions. This means that they cannot be used in places where expressions are expected such as the RHS of an assignment or as a function argument.

We propose using if as expressions and requiring the use of else when it is used as an expression.

Additionally, if expressions should be able to omit parenthesis around the condition, while curly braces should always be required around the body.

LightningScript.⚡️
// If expression
let x =
if y < 0 {
"negative"
} else if y === 0 {
"zero"
} else {
"positive"
}
TypeScript.tsx
let x =
y < 0
? "negative"
: y === 0
? "zero"
: "positive";

if statements should have optional parenthesis and required curly braces, as well.

LightningScript.⚡️
if y < 0 { // parenthesis optional
console.log("negative");
} else if y === 0 { // curly braces required
console.log("zero");
} else {
console.log("positive");
}
TypeScript.tsx
if (y < 0) {
console.log("negative");
} else if (y === 0) {
console.log("zero");
} else {
console.log("positive");
}

Range Literals#

Introduce two new operators ..= and ..< to represent inclusive and exclusive ranges respectively.

These ranges are iterators that can be used in for..of loops and can be used to create arrays using the spread operator. They are not arrays themselves.

LightningScript.⚡️
const oneToTen = 1..=10;
const oneToNine = 1..<10;

for (const i of 1..=10) {
console.log(i);
}

TypeScript.tsx
function* range(start, end) {
for (let i = start; i < end; i++) {
yield i;
}
return;
}
const oneToTen = range(1, 10);
const oneToNine = range(1, 9);

for const i of range(1, 10) {
console.log(i);
}

NOTE: The actual implementation could use a custom iterator that is more efficient than the generator function.

Array/String Slicing#

Range literals can be used to slice arrays and strings.

LightningScript.⚡️
const start = numbers[..<2]
const mid = numbers[3..=-2]
const end = numbers[-2...]
numbers[1..=-1] = []
TypeScript.tsx
const start = numbers.slice(0, 2);
const mid = numbers.slice(3, -2 + 1);
const end = numbers.slice(-2);
numbers.splice(1, -1 - 1, ...[]);

Pattern Matching#

TC39 Proposal: Pattern Matching

The TC39 proposal for pattern matching creates a new expression called match and uses a new keyword when to match patterns.

When not encumbered with backwards compatibility, we propose using the switch keyword to support pattern matching using the familiar case keyword. The let keyword to "capture" values from the pattern, which is also part of the TC39 proposal.

Further to improve ergonomics, the parenthesis around the switch expression there is no fallthrough by default so break statements are not required.

Prior art: Swift.

LightningScript.⚡️
switch x {
case 0:
console.log("zero");
case /^s+$/:
console.log("whitespace");
case [{ type: "text", let content }, let ...rest]:
console.log("leading text", content);
console.log("rest", rest);
}
TypeScript.tsx
if (x === 0) {
console.log("zero");
} else if (
typeof x === "string" &&
/^\s+$/.test(x)
) {
console.log("whitespace");
} else if (
Array.isArray(x) &&
x.length >= 1 &&
typeof x[0] === "object" &&
x[0] != null &&
"type" in x[0] &&
x[0].type === "text" &&
"content" in x[0]
) {
const [{ type, content }, ...rest] = x;
console.log("leading text", content);
}

This new switch will also work as an expression where each "case" block implicitly returns the last expression.

LightningScript.⚡️
let x = switch y {
case 0..=9:
"digit";
case let n if typeof n === "number":
"number";
case /^s+$/:
"whitespace";
case [{ type: "text", let content }, let ...rest]:
let prefixedContent = "leading text " + content;
[prefixedContent, ...rest]; // implicit return
}

is Operator#

TC39 Proposal: Pattern Matching

The TC39 proposal for pattern matching includes a new is operator to JavaScript. We propose using this operator and removing the instanceof and typeof operators.

LightningScript.⚡️
if x is String {
console.log(x.length);
}

if x is Number {
console.log(x.toFixed(2));
}

if x is Array {
console.log(x.length);
}

if x is Object {
console.log(Object.keys(x));
}

if x is NaN {
console.log("NaN");
}

if x is undefined {
console.log("undefined");
}

if x is Person {
console.log(x.name);
}

if x is Person && x is { name } {
console.log(name);
}
TypeScript.tsx
if (typeof x === "string") {
console.log(x.length);
}

if (typeof x === "number") {
console.log(x.toFixed(2));
}

if (Array.isArray(x)) {
console.log(x.length);
}

if (typeof x === "object" && x != null) {
console.log(Object.keys(x));
}

if (Number.isNaN(x)) {
console.log("NaN");
}

if (typeof x === "undefined") {
console.log("undefined");
}

if (x instanceof Person) {
console.log(x.name);
}

if (x instanceof Person && "name" in x) {
console.log(x.name);
}

Pipeline Operator#

TC39 Proposal: Pipeline Operator

We propose adding a |> Pipe operator to JavaScript. We propose using the Hack-style operator which is the proposal that is currently winning proposal at TC39.

We are proposing adopting the TC39 proposal as-is and using the _ character as a placeholder for the LHS of the expression.

As a result of using _ as the placeholder, _ will no longer be a valid identifier within a pipeline expression.

LightningScript.⚡️
// From react/scripts/jest/jest-cli.js.
envars
|> Object.keys(_)
|> _.map(envar => `${envar}=${envars[envar]}`)
|> _.join(' ')
|> `$ ${_}`
|> chalk.dim(_, 'node', args.join(' '))
|> console.log(_);
TypeScript.tsx
// From react/scripts/jest/jest-cli.js.
console.log(
chalk.dim(
`$ ${Object.keys(envars)
.map(envar => `${envar}=${envars[envar]}`)
.join(' ')}`,
'node',
args.join(' ')
)
);
LightningScript.⚡️
// From express/lib/response.js.
return links
|> Object.keys(_)
|> _.map((rel) => '<' + links[rel] + '>; rel="' + rel + '"')
|> link + _.join(', ')
|> this.set('Link', _);
TypeScript.tsx
// From express/lib/response.js.
return this.set(
'Link',
link + Object
.keys(links)
.map((rel) => '<' + links[rel] + '>; rel="' + rel + '"')
.join(', '),
);

When using the pipe operator, functions that take the placeholder value as the first argument should show up as auto-complete suggestions.

Rest Operator in Any Position#

Rest properties/parameters/elements should no longer limited to the final position.

LightningScript.⚡️
const [...head, secondLast, last] = [1, 2, 3, 4, 5]

let {a, ...rest, b} = {a: 7, b: 8, x: 0, y: 1}

function justDoIt(a, ...args, cb) {
cb.apply(a, args)
}

// Omit middle elements with `_`
const [first, ..._, last] = array
TypeScript.tsx
const [...head] = [1, 2, 3, 4, 5];
const [secondLast, last] = head.splice(-2);

const { a, b, ...rest } = { a: 7, b: 8, x: 0, y: 1 }

function justDoIt(a, ...args) {
let [cb] = args.splice(-1);
return cb.apply(a, args);
}

const [first, ...ref] = array;
const [last] = ref.splice(-1));

Better JSX#

JSX has become a defacto standard for building user interfaces on the web. However, we can improve the ergonomics of JSX by making a couple of small changes:

  1. Require quotes around string literals as children
  2. Remove the need for {} around expressions as children
  3. Allow prop name punning

This approach make JSX less like HTML and more like JavaScript.

LightningScript.⚡️
const className = "foo";
const element =
<div>
<span {className} inert={false}>

"Hello, world!"

if (x === 0) {
<span>zero</span>
} else if (x === 1) {
<span>one</span>
} else {
<span>many</span>
}

</span>
</div>

const fetchResult =
<Fetch url={API_URL}>

switch (props) {
case { loading }:
<Loading />
case { error }:
<Error error={error} />
case { data }:
<Page data={data} />
}

</Fetch>
TypeScript.tsx
const className = "foo";
const element = (
<div>
<span className={className} inert={false} {...props}>

Hello, world!

{
x === 0 ? (
<span>zero</span>
) : x === 1 ? (
<span>one</span>
) : (
<span>many</span>
)
}

</span>
</div>
);

const fetchResult = (
<Fetch url={API_URL}>

{Object.keys(props).includes('loading') ?
<Loading />
: Object.keys(props).includes('error') ?
<Error error={error} />
: Object.keys(props).includes('data') ?
<Page data={data} />
: null
}

</Fetch>
)

Since {} are no longer required around expressions, you should be able to use if expressions and switch expressions

Type System Features#

The following are features that would depend on a robust type system.

Tagged Enums#

TypeScript enums are have some idiosyncracies that make them less than ideal and they are not powerful enough to be used in place of tagged unions.

We propose expanding the capabilities of enums to make them more powerful.

LightningScript.⚡️
enum Color {
Red,
Green,
Blue,
RGB(r: number, g: number, b: number),
HSL(h: number, s: number, l: number),
}
TypeScript.tsx
type Color =
| "Red"
| "Green"
| "Blue"
| { type: "RGB"; r: number; g: number; b: number }
| { type: "HSL"; h: number; s: number; l: number };

These enums should support pattern matching using the switch expression.

Also, when the enum type is know, it should be be possible to match on the possible enum cases with a leading ..

LightningScript.⚡️
const colorString = switch color {
case Color.Red:
"red";
case .Green:
"green";
case .Blue:
"blue";
case .RGB(let r, let g, let b):
`rgb(${r}, ${g}, ${b})`;
case .HSL(let h, let s, let l):
`hsl(${h}, ${s}, ${l})`;
}
TypeScript.tsx
const colorString = color === 'Red'
? "red"
: color === 'Green'
? "green"
: color === 'Blue'
? "blue"
: color.type === "RGB"
? `rgb(${color.r}, ${color.g}, ${color.b})`
: color.type === "HSL"
? `hsl(${color.h}, ${color.s}, ${color.l})`
: null;

Typed Errors#

Prior art: Swift.

This change would require a big enhancement to the Type-System, but would result in more predictable and performant code.

This proposal involves three changes:

  1. Every function that can error should be marked as such.
  2. try-catch blocks should be replaced with do-catch blocks.
  3. A try keyword must be required when calling a function that can error.

Let's consider these changes in more detail.

Consider a function such as JSON.parse which can throw an error if the JSON is invalid. The fact that it can throw should be part of its type signature.

LightningScript.⚡️
type JSON = {
parse(json: string) throws : unknown;
}
TypeScript.tsx
type JSON = {
/**
* Function can throw. Typesystem doesn't track it.
* @throws
*/
parse(json: string): unknown;
}

When calling any function that can throw an error, the try keyword must be used.

LightningScript.⚡️
const data = try JSON.parse(json);
TypeScript.tsx
// Typescript doesn't doesn't know that this function can throw.
// All we have is comments.
// NOTE: This function can throw.
const data = JSON.parse(json);

But any usage of try itself must be within a do-catch block or a function that can error.

LightningScript.⚡️
function getNameFromJSON(json: unknown) throws: null | string {
const data = try JSON.parse(json)
if data is { name } && name is String {
return name
}
return null
}

// OR
function getNameFromJSON(json: unknown): null | string {
do {
const data = try JSON.parse(json)
if data is { name } && name is String {
return name
}
return null
} catch(error) {
console.error(error)
return null
}
}
TypeScript.tsx
/**
* Function can throw. Typesystem doesn't track it.
* @throws
*/
function getNameFromJSON(json: unknown): null | string {
const data = JSON.parse(json)
if (typeof data === "object" &&
data != null &&
"name" in data &&
typeof data.name === "string"
) {
return data.name;
}

return null
}

function getNameFromJSON(json: unknown): null | string {
// Rememver to catch your errors.
// Typescript doesn't help you with them.
try {
const data = JSON.parse(json)
if (typeof data === "object" &&
data != null &&
"name" in data &&
typeof data.name === "string"
) {
return data.name;
}
return null
} catch(error) {
console.error(error)
return null
}
}

Additionally, a convenience try? keyword should be available to return undefined instead of throwing an error.

LightningScript.⚡️
function getNameFromJSON(json: unknown): ?string {
// data is `unknown | undefined`
const data = try? JSON.parse(json)

if data is { name } && name is String {
return name
}
return null
}
TypeScript.tsx
function getNameFromJSON(json: unknown): ?string {
try {
// Error can only be caught with a `try-catch` block.
const data = JSON.parse(json)
if (typeof data === "object" &&
data != null &&
"name" in data &&
typeof data.name === "string"
) {
return data.name;
}

return null
} catch {
return undefined;
}

}

Syntax changes#

Using from first for Import Statements#

The from keyword should be used first in import statements to improve auto-complete suggestions.

LightningScript.⚡️
from "react" import { useState, useEffect }
TypeScript.tsx
import { useState, useEffect } from "react";

Import attributes should still be supported

LightningScript.⚡️
from "react/package.json" import { version } with { type: "json" }
TypeScript.tsx
import { version } from "react/package.json" with { type: "json" };

Loops and Conditionals Require Curly Braces#

As mentioned above for if and switch expressions, all loops and conditionals should require curly braces while parenthesis should be optional.

LightningScript.⚡️
for let i of 0..10 {
console.log(i);
}

// Curly brackets required.
for let i = 0; i < 10; i++ {
console.log(i);
}

while x < 10 {
console.log(x);
x++;
}

if x < 0 {
console.log("negative");
} else if x === 0 {
console.log("zero");
} else {
console.log("positive");
}

switch x {
case 0:
console.log("zero");
case /^s+$/:
console.log("whitespace");
case [{ type: "text", content }, ...let rest]:
console.log("leading text", content);
console.log("rest", rest);
}
TypeScript.tsx
for (let i of range(0, 10))
console.log(i);

// Optional curly brackets.
for (let i = 0; i < 10; i++)
console.log(i);

while (x < 10) {
console.log(x); x++;
}

if (x < 0) {
console.log("negative");
} else if (x === 0) {
console.log("zero");
} else {
console.log("positive");
}

if (x === 0) {
console.log("zero");
} else if (
typeof x === "string" &&
/^\s+$/.test(x)
) {
console.log("whitespace");
} else if (
Array.isArray(x) &&
x.length >= 1 &&
typeof x[0] === "object" &&
x[0] != null &&
"type" in x[0] &&
x[0].type === "text" &&
"content" in x[0]
) {
const [{ type, content }, ...rest] = x;
console.log("leading text", content);
}

We also remove the do-while and for-in loops as they are rarely used and can be replaced with while and for-of loops respectively.

Remove var#

The var keyword should be removed from the language. Only let and const should be used.

Replace the function keyword with fn#

The function keyword should be replaced with fn to reduce verbosity.

LightningScript.⚡️
fn add(a: number, b: number): number {
return a + b;
}
TypeScript.tsx
function add(a: number, b: number): number {
return a + b;
}

Fix ASI and Make Semi-Colons Optional#

LightningScript should reliably work without semi-colons.

JavaScript has ASI (Automatic Semicolon Insertion) which means that semi-colons are optional in many cases. However, there are pitfalls with ASI that can cause unexpected behavior.

Even when using semi-colons, there are still pitfalls with ASI:

return
"Hello, world!";

Even though it looks like the function returns "Hello, world!", it actually returns undefined.

To make semi-colons truly optional, we would need to remove these pitfalls by making the following changes to syntax:

  1. Disallow whitespace before the ( when invoking a function
  2. Disallow whitespace before [ during Array or Object indexing.
  3. Disallow unary return, yield and throw statements.

The following patters would be disallowed:

const sum = add (1, 2);

const sum = add
(1, 2);

const first = array [0];

const first = array
[0];

return;

yield;

throw;

However the following patterns would still be allowed:

const sum = add(1, 2);

const sum = add(
1,
2
)

const first = array[0];

const first = array[
0
]

return undefined
return null

throw new Error("error")

yield undefined
yield null

As a result of these changes, the following patterns would be allowed, which are currently not allowed in JavaScript:

LightningScript.⚡️
yield
1 + 1

return
if (x === 0) {
"zero"
} else if (x === 1) {
"one"
} else {
"many"
}

return
<div>
"Hello, world!"
</div>
TypeScript.tsx
yield (
1 + 1
);

return (
x === 0 ? (
"zero"
) : x === 1 ? (
"one"
) : (
"many"
)
);

return (
<div>
Hello, world!
</div>
);

This syntax change should be unsurprising to JavaScript developers and make code even more intuitive.

Use is instead of extends for Generic Type Guards#

TypeScript uses the extends keyword to define type guards. We propose using the is keyword instead.

LightningScript.⚡️
fn id<T is string | number>(x: T): T {
return x
}
TypeScript.tsx
function id<T extends string | number>(x: T): T {
return x
}

The extends keyword should still be used for extending classes, but never for Types.

The is keyword used within the return type for type-guards should remain unchanged.

Use if expressions for conditional types#

TypeScript has a feature called "Conditional Types" which are used to create generic types that depend on a condition.

This feature uses a ternary operator to create the condition, but, being consistent with other changes we propose using if expressions instead.

LightningScript.⚡️
type NonNullable<T> =
if T is null | undefined {
never
} else {
T
}
TypeScript.tsx
type NonNullable<T> =
T extends null | undefined
? never
: T;

We can consider adding a switch expression to TypeScript to make this feature even more powerful.

Use ? symbol to mark optional types.#

Instead of having to write null | undefined | string, you should be able to write string?.

The only other usages of ? in LightningScript would be for optional chaining (a?.b?.[0].?()) and null coalescing (??).

LightningScript.⚡️
type Person = {
name: string?
age: number?
}
TypeScript.tsx
type Person = {
name: string | null | undefined
age: number | null | undefined
}

Remove Ternary Expressions#

Ternary expressions are famously hard to read and even with heroic efforts from Prettier, there seems to be no obvious way to format them that makes them readable for even a majority of developers.

Prettier Issue about Ternary Formatting

With support for if expressions for both code and types, there is no need to keep ternary expressions in the language.

We can remove ternary expressions from both code and conditional types.