Creating tools for Developers

DATE 2025-03-07 -- TIME 07:11:49

Description

extra/README

## The Extra Programming Language

TL;DR

bun run repl

Extra is a strongly-typed language and runtime that can be used to create client-side applications (and other things, I suppose but it’s aimed at frontend). It’s closest cousin is Elm, second cousin to React, long-time-listener-first-time-caller to Svelte, and uncanny valley similarity to TypeScript.

OK, tell me moooore…

While Elm made good on the promise of being extremely well-reasoned, it was painful, to me, to compose components that needed to track their own internal state. Extra makes that really easy – but still explicit.

It will also feel familiar to React developers, but without the cognitive dissonance of “let it render” and “prevent too many rerenders”, and obviously not the “this was your best idea?” mess that is hooks. Whenever someone says “React is declarative!” I die a little inside.

The big difference in Extra with all these frameworks is how views are updated. Think spreadsheets instead of DOM diffing.

When you update a cell in a spreadsheet, the application is able to know exactly what cells were depending on that cell. It can create a dependency graph of all the downstream dependencies, including charts and pivot tables, triggers, etc, and only update what is needed. This is eerily similar to the goal that React and other virtual-dom-based frameworks attempted… but they work on a “render-and-diff” model instead of “render-what-changed”. Extra tries to change that.

In Extra, your <View/> components create a runtime that is capable of tracking atomic changes. Think “assign new string value” and “push to an array”. These atomic changes are handed to the components that were depending on that value, and the changes are propogated to the corresponding view object (dom or native view).

I’m completely sold! But show me some more cool things nonetheless.

Before I jump into the application architecture, let’s get to know Extra first. Because on top of being a really interesting runtime, it’s also a pretty-darn-good™ programming language!

Quick syntax primer

-- comments are hyphenated, like Ada and Lua
{- or nested like this -}
<-- also this! Finally you can *point* to things using comments.

-- `let` is a special language construct that assigns values to scope.
let
  name = "Extra"
  someNumber = 2 * 1 + 40
  fn format(#name: String, age: Int) =>
    "Hello, $name!"
in
  format(name, age: someNumber)


let
  max = 10
  -- hyphens are allowed in names
  -- functions close-over local variables (`max`)
  -- the return type `Boolean` is inferred
  fn is-divisible-by-3(num: Int) =>
    num % 3 == 0 and num < max

  -- curly brackets are required in `if` expressions, but they surround the entire
  -- expression. This is actual an "external argument" syntax that can be used to
  -- create your own DSLs
  evens = if(max == 10) {
    then:
      [2, 4, 6, 8, 10]
    elseif(max == 12):
      [2, 4, 6, 8, 10, 12]
    else:
      [2, 4, 6, 8, 10, 12, 14]
    }
  }
  odds = [
    1   -- look ma, no commas!
    3
    5
    7
    -- alternative way to invoke 'if' (no ternary operator)
    ...if(max <= 10, then: [9], else: [])
  ]
in
  [...evens, ...odds]
    .filter(is-divisible-by-3)
    .sort(by: fn(a, b) => a <=> b) --> [3, 6, 9]
  -- the pipe operator assigns the left-hand-side to the `#` symbol
  |> inspect('filter', #)  --> prints [3, 6, 9]: [Int] and returns that value
  -- but if you want to assign # to a name, you can use `=>`
  |> some-numbers => some-numbers.map(fn(num) => $num).join(',')

-- there's a JSX-ish syntax built in
-- arrays, dicts, sets, and objects support an inclusion operator `?`
-- in this case, 'italic' is included in the array only if `@is-italic` is true
<p class=['bold', @is-italic ? 'italic']>Hello, World!</p>

Language Features

There are a few things that I always thought would be handy in a programming language, and so I put them in here.

Variable names

References can have hyphens like in Lisp (valid-variable-name).

Comments

I may have gone a bit overboard, just a heads up. 🤓

-- line comment {- block -} {- block {- with nesting -} -} --> arrow style line comment <-- alternate arrow style line comment

The usual comment characters # and // both have special meaning in Extra, and so I looked elsewhere for inspiration, and looked no further than Ada (and yes, Ada, Elm, Lua all use -- for line comments… but Ada has a lot more hacker cred so I wanted to mention it first).

-- this is a line comment
"no longer a comment"  <-- this is a statement (and this is a comment!)

{- comment block, line 1
 {- comment blocks _can_ be nested -}
comment, line 3 -}

-- Handy trick to comment/uncomment multiple lines easily:
{--}    <-- removing the '}' here will turn all four lines into a comment
multiple |>
  lines
--} <-- This brace is just part of a line comment until the '}' above is removed

--> arrows can be a comment! It's a small thing, but I find this so handy.
<-- so much so that I made `<--` a comment marker, too.
-- and <- is a "binding", which is used in views to bind getters/setters.

Commas are optional

I’ve tried hard to make sure the language grammar can unambiguously determine whether you are still writing an expression, or starting a new one. This allows for arrays, function-arguments, and imports to have commas as optional.

[
  1
  2
  3
] --> [1, 2, 3]

{
  name: 'Extra'
  is-awesome: true
  awesome-level: 11
}

add-two-numbers(
  1
  2
) --> 3

import Math: {
  sqrt
  pow
} --> import `sqrt` and `pow` functions from the Math package

Pattern Matching, Implication Operator

Obviously Extra supports pattern matching. This feature makes heavy use of the => operator, which I’ve named the ”implication operator”. It’s already the function body marker, and so plays natural double-duty to indicate case expressions.

switch (volume) {
  case: 0..<2 => 'turn it up!'
  ase: 2..<5 => "that's enough"
 ase: num => `$num is too loud`

If the implication is on the right hand side of the pipe operator, it will be invoked with the # value.

httpResponse |>
  Result.Success(success) => success.message
--> Maybe<String>

-- more generally, though, you could pipe into `switch`
httpResponse |> switch(#) {
  esult.Success(success) => success.message
  esult.Failure(error) => error.message
--> String

-- this is also a handy way to "name" the `#` value:
su(numbers) |> sum =>
 f(sum > 10, then: 'big sum', else: 'small sum')

Functions

Functions are bonkers. They support positional and named arguments, along with all sorts of variadic arguments. The order you have them in the function definition will determine the order that the source-code formatter (extra-normal) orders the arguments.

Positional arguments have a # prefix, #like: This. Named arguments do: Not. Named arguments can be aliased like so: GotIt?. Variadic arguments ...#are: LikeThis or ...like: This.

Examples:

fn doEeet(#count: Int, #name: String = '', age: Int = 0, reason why: String) =>fn body-- #count is required
-- #name is optional (default value provided)
-- age is optional, and is a named argument
-- reason is required. doEeet() must be called with the reason: argument,
-- but the fn body uses the name "why"

doEeet(1, reason: '')                   -- name = '', age = 0
doEeet(1, 'foo', reason: '')            -- name = 'foo', age = 0
doEeet(1, 'foo', reason: '', age: 42)   -- name = 'foo', age = 42doEeet(reason: '')                    -- #count is requireddoEeet(1)                             -- reason is required

If the argument type is null-able, you can make the argument optional like?: This (like: This | null). If the argument is generic, it will be made optional only if the type is null-able. In otherwords

fn first-or<T>(#array: Array(T), else fallback?: T) =>
  if(array) {
  then:
    array[0]
  else:
    fallback
  }

let
  a: Array(Int) = […]
  b: Array(Int?) = […]
in
  first-or(a, else: 1) --> else is required because type `Int` is not nullable
  first-or(b, else: 1) --> still fine here, but...
  first-or(b)          --> else is optional (defaults to `null`) because `Int?` aka `Int | null` is nullable

Confusing! Sorry, it is, but I also think it is useful.

Inferred types

The return type can always be inferred. Argument types are required when you are defining a function (in let or Helpers section), but if you are calling a function that expects a function, like map, reduce, sort, you can omit the argument types. The trick here is that the receiving function will define the types, so in this case you don’t have to.

[1, 2, 3].map(fn(num) => num + 1) --> [2, 3, 4]

In the example above, num is a named argument, but map expects a function that accepts two positional arguments #value: T, index: Int. Since the first named argument is compatible with #value: Int, the compiler figures out what to do.

Variadic Arguments

There are three brands of variadic arguments.

  • (1) variadic positional arguments - must be an Array type
  • (1) variadic named arguments - must be a Dict type
  • (N) repeated named arguments - must be an Array type

Variadic Positional Arguments

These combine well with refined Array types, for instance, we can implement add as a variadic function, but require a minimum number of arguments.

fn add(...#numbers: Array(Int, >=2)) =>
  numbers.reduce(0, fn(memo, num) => memo + num)

add(1, 10) --> 11
add(1, 10, 31) --> 42add() -- not enough argumentsadd(1) -- not enough arguments

let
  numbers = [1, 10, 31]
in
  add(...numbers)

Variadic Named Arguments

Any argument names that are not otherwise declared in the arguments will be put into *remaining: Dict(String, T).

fn list-people(greeting: String = 'Hi, ', *people: Dict(String)) =>
  words.map((name, value) =>
    `$greeting$name: $value`).join('\n')


list-people(greeting: 'Hello, ', jane: 'doctor', emily: 'dumb lawyer')

let
  people = dict(jane: 'doctor', emily: 'dumb lawyer')
in
  list-people(*people)

Repeated Named Arguments

You can specify the same argument by name, multiple times.

fn switch<T, U>(#value: Y, ...case: Maybe<U>, else?: U): U

switch(1, case: 1 => 'one', case: 2 => 'two', else: 'who knows') --> 'one'

Blocks and Lazy types

Arguments can be marked lazy, in which case they look like a value at the call-site, but are not evaluated until the parameter is invoked.

Arguments can also be provided outside of the function using two syntaxes:

-- "simple" argument
foo(): 1  --> same as foo(1), only supports one "outside" argument
foo() { 1 }  --> same as foo(1), supports any number of arguments, including named
foo() { 1, else: 2 }

Here is a function definition using lazy arguments:

fn doSomething<T>(condition: 1 | 2 | 3, one: lazy(T), two: lazy(T), three: lazy(T)) =>
  switch (condition) {
    case: 1 => one()
    case: 2 => two()
    case: 3 => three()
  }

-- usually you would call the function like this - "vanilla" extra code
doSomething(1, one: 1, two: 2, three: 3) --> 1

-- but the named arguments DSL allows this:
doSomething(1) {
  one: 1
  two: 2
  three: 3
} --> 1

Literals

switch (value)
  when 1 => 'one'
  when 2 => 'two'
  else => 'not one or two'

Enum matching

Result<Ok, Err> = enum
    | Ok(value: Ok)
    | Err(error: Err)

fn result-to-maybe<T>(result: Result<T, unknown>) =>
  switch (result)
    when Ok(value) => value
    else => null

Destructured matching

This was hard so you better like it.

-- foo: String | Array(String)
switch (foo)
  when 'foo' ++ bar => bar
  when ['foo', ...a] => a.join(',')
  else => 'not "foo…" or [a, …]'

Not every operator is supported in this way, but I tried to support everything that makes sense. Arrays can only have one ... splat, strings can only end in one ++ var, values can be ignored using _, the usual stuff.

Unambiguous operators

Minor thing: + is a mathematical operator that adds two numbers. Did you know that a + b == b + a? Except in Java and Javascript and Swift and many other languages. 🙄 ++ is a computer science-y operator that concatenates two lists (strings, arrays, or merges two dicts).

Words are used for logical operators, but not bitwise operators.

String coercion and interpolation

Extra’s “coerce to String” (toString()) is a unary operator $, and it’s also the string interpolation delimiter.

-- look at the beautiful similarity between String templates
-- and String coercion:

"How many: $n"
"How many: " ++ $n

-- String coercion will happen in the case of
-- String interpolation and the concatenation operator ++,
-- but not for function arguments that expect a String

-- because it's an _operator_, you can do things like
[1, 2].join($(n + 1))

Type guards

You can provide much more type information to Arrays, Dicts, Sets, Strings, and Numbers. You can define types like “an Array of Ints, with at least one item, where each Int is greater than 0” ([Int(>0), 1+]).

In my mind, an “empty String/Array” is a different type than “a String with 5 or more characters.” And the reason they are different types is because there are often cases where I know that I will need at least one of the thing. For instance, a name: String variable. Would’t it be nice if I could say name: String(1+), indicating that it must have at least one letter? Yes we can!

String(length: =8)      -- String of exactly length 8
String(matches: /^\d!$/) -- String matching a regex
String(matches: [/^.\d+!$/, /^a/]) -- String matching multiple regexes

Int(<8)       -- any Int less than 8
Int(0...10)    -- any Int 0 to 10, inclusive
Float(0..<10) -- any Float greater than or equal to 0, less than 10
Float(-10<.<10) -- any Float greater than -10, less than 10

Int(=8)       -- this is just the literal number 8
Int(8...8)    -- so is this!
Int(7<.<9)    -- and this.
8             -- literals are also valid types

Array(Foo, length: >=3)     -- Array of type 'Foo' with at least 3 items
Array(Foo, length: <=3)     -- Array of Foo with 3 items or less
Array(Foo, length: =3)     -- Array of Foo with exactly 3 items

-- < <= >= > comparisons also work
Array(Foo, length: <=3)    -- array of Foo with no more than 3 items
Array(Foo, length: >3)     -- array of Foo with more than 3 items

-- and ranges
Array(Foo, length: 2...4)   -- array of Foo with 2, 3, or 4 items (inclusive range)
Array(Foo, length: 1<.<5) -- array of Foo with 2, 3, or 4 items (exclusive range)
Array(Foo, length: 2..<5) -- array of Foo with 2, 3, or 4 items (exclusive range)
Array(Foo, length: 2<..5) -- array of Foo with 3, or 5 items (exclusive range)

-- Dict / Maps
Dict(Foo, length: 3+)      -- dict of Foo with 3 or more items
Dict(Foo, length: 3...10)  -- dict of Foo with 3 to 10 items in it

Dict(Foo, keys: [key1:, key2:])  -- dict with specified keys - these keys must be present

Dict(Foo, keys: [key1:, key2:])  -- dict with specified keys - these keys must be present
Dict(Foo, keys: [key1:, key2:], length: 3+)  -- specified keys and length >= 3

-- these types can be combined:
Array(String(length: =8), length: =10) -- array of strings
      -- each string is 8 characters
                  -- and there are 10 of them in the array
Array(length: =10, String(=8)) -- if you prefer, these arguments can be rearranged

Default value placeholder.

For situations where you are calling a function that offers a default value. Imagine a scenario where in some cases you want to specify the argument, and in other cases you want to use the default.

I’ve chosen the name fallback for this value. default is the obvious choice, but I found myself wanting to use that name in argument lists, and so decided to make it something a little more esoteric/special.

Case 1

You only want to specify 1st and 3rd positional arguments.

foo(1, fallback, 3)

This calls the function foo with the first and third arguments specified, but the second argument will defer to the default value. So simple, so handy. What is the default value in this case? I dunno! Should I know? Do I look up the API for that? What if it changes?

Case 2

If b is specified, use it, otherwise use the default.

let
  fn bar(#a: Int, #b: Int = 10) => a + b
  fn foo(#a: Int, #b: Int | null) =>
    bar(a, b ?? fallback)
in
[
  foo(1),    --> 11, default value of 10 is used
  foo(1, 1), --> 2
]

In other languages, in order to avoid hard-coding b’s default value 10 you would have to provide two separate calls to bar:

fn foo(#a: Int, #b: Int | null) =>
  if(#b == null) {
  then:
    bar(a)
  else:
    bar(a, b)  -- 🤢
  }

It really shakes my pepper that this doesn’t exist in more languages! How is this not a thing!? I’ve often felt that I wanted this. Maybe it’s just me. 🤷‍♂️

Pipe operator 🤓

I’m a big fan of pipes from Elm and Elixir. In these languages, the value entering the pipe is automatically inserted into the receiving function. I think that having a sigil represent where you want the value to go gives them even more flexibility. Slow approving nod to Hack for this idea.

I picked the # character, because it’s also used in functions as a “positional argument” indicator. JS’s proposal currently favors ^^ I think? 🤢 Why can’t JS do anything right… and why don’t they just ask me, since I seem to know all the answers.

'abc' |> # ++ #
  --> 'abc' ++ 'abc'
  --> 'abcabc'

-- extract two elements from an object, place them in an array
{a: 'a', b: 'b', c: 'c'} |> [#.a, #.b]

Also available is the “null coalescing pipe”. If the value is null, it skips the pipe and returns null. Otherwise, invokes the pipe with the non-null value. Elm would call this Maybe.map. Haskell would call this - ok I had to look this up and I got confused so I don’t know what Haskell would call this. >>= or maybe <*$>.

let
  a: String? = 'bang'
  b: String? = null
  fn example(#foo: String?) => foo ?|> # ++ "!"
in
  [
    example(a) --> 'bang!'
    example(b) --> null
  ]

Algebraic data types of course

In particular: Sum Types. Shoutout to Justin Pombrio – but please get out of my head and stealing my rants.

RemoteData<Success, Failure> = enum
    | NotAsked
    | Loading
    | Failure(error: Failure)
    | Success(value: Success)

Product Types in Extra are the good ol’ Object type – Record or struct in other languages. Extra Objects are also Tuples, because the property name is optional - you can have positional and named properties (which aligns them with how function arguments support positional and named arguments - function arguments are just Tuples/Objects!)

Insane Comments

This is maybe a little out of hand, but I like drawing boxes using old-school ASCII characters, so there’s support for these as line-comment start characters.

All box-drawing characters are also valid comments (U+2500 – U+257F).

╭────────╮
│  yup.  │  ┌─╴╴╴╴╴╴╼┓
╰────────╯  │go nuts!╿
            ╘════════╛

Here’s the complete set, so you can copy/paste your favourites:

       0 1 2 3 4 5 6 7 8 9 A B C D E F
U+2500 ─ ━ │ ┃ ┄ ┅ ┆ ┇ ┈ ┉ ┊ ┋ ┌ ┍ ┎ ┏
U+2510 ┐ ┑ ┒ ┓ └ ┕ ┖ ┗ ┘ ┙ ┚ ┛ ├ ┝ ┞ ┟
U+2520 ┠ ┡ ┢ ┣ ┤ ┥ ┦ ┧ ┨ ┩ ┪ ┫ ┬ ┭ ┮ ┯
U+2530 ┰ ┱ ┲ ┳ ┴ ┵ ┶ ┷ ┸ ┹ ┺ ┻ ┼ ┽ ┾ ┿
U+2540 ╀ ╁ ╂ ╃ ╄ ╅ ╆ ╇ ╈ ╉ ╊ ╋ ╌ ╍ ╎ ╏
U+2550 ═ ║ ╒ ╓ ╔ ╕ ╖ ╗ ╘ ╙ ╚ ╛ ╜ ╝ ╞ ╟
U+2560 ╠ ╡ ╢ ╣ ╤ ╥ ╦ ╧ ╨ ╩ ╪ ╫ ╬ ╭ ╮ ╯
U+2570 ╰ ╱ ╲ ╳ ╴ ╵ ╶ ╷ ╸ ╹ ╺ ╻ ╼ ╽ ╾ ╿

┌─┬─┐ ╒═╤═╕ ╓─╥─╖ ╔═╦═╗
│ │ │ │ │ │ ║ ║ ║ ║ ║ ║
├─┼─┤ ╞═╪═╡ ╟─╫─╢ ╠═╬═╣
│ │ │ │ │ │ ║ ║ ║ ║ ║ ║
└─┴─┘ ╘═╧═╛ ╙─╨─╜ ╚═╩═╝

┍━┯━┑ ┎─┰─┒ ┏━┳━┓ ╭─┬─╮
│ │ │ ┃ ┃ ┃ ┃ ┃ ┃ │ │ │
┝━┿━┥ ┠─╂─┨ ┣━╋━┫ ├─┼─┤
│ │ │ ┃ ┃ ┃ ┃ ┃ ┃ │ │ │
┕━┷━┙ ┖─┸─┚ ┗━┻━┛ ╰─┴─╯

Extra Applications

Not only that, but Extra programs encourage these box drawing comment characters. Extra applications use section headers to organize the different roles. These sections must be defined in exactly this order, using exactly these names, using the round-single-line border. Except Imports, which must be at the top of the file, and don’t require a section header.

import /FromFile1
import ./FromFile2: { TypeName, helperName, Main as FromFile2 }

╭───────╮
│ Types │
╰───────╯

The sections are:

  • Types – where you define type aliases, classes, interfaces, etc.
  • @State - where you define the internal state of your app/compnent
  • <Main /> - the entry point for your app or component
  • &Actions - your <Main /> component will emit actions, which modify state, which modify <Main />… rinse & repeat
  • <Views> - components that you can call from <Main />
  • Helpers() - functions to assist with rendering or actions or whatever

Types

Define your custom types in here. Types must be capitalized.

╭───────╮
│ Types │
╰───────╯
Age = Int(>=0)
Name = String(>0)

-- this looks like an object definition, but is actually more like an interface, due to loose type checking
Point = {x: Int, y: Int}

-- object types can have functions defined on them
User = { name: Name, age: Age, foo() => 'foo' }

Student = User & {
  grade: Int
  summary() =>
    -- string concat and two string embed examples
    -- '.' operator would be `this` or `self` in many languages
    .name ++ " is ${.age} years old and is in grade $.grade"
}

State

Define your initial state using “slots” and “formulas”. They must begin with a lowercase letter.

╭────────╮
│ @State │
╰────────╯
-- these are slots, they can be modified or reassigned using Actions
@users: [User] = [ {name: …}, … ]
@students: [Student] = [ {name: …, grade: 1} ]

-- this is a formula, and is computed based on slots
-- slots (and formulas) must be referred to using the `@` sigil,
-- to distinguish them from local variables
@allUsers = @users ++ @students

-- allUsers will be inferred as [User]

One trick to working with this slot/formula system is to make use of overridables:

-- provide an "overridable" slot
public @overrideUsers: [User] | null = null

-- use it in a formula
@allUsers = @overrideUsers ?? @users ++ @students

Or, think in terms of spreadsheets - how would you do it in VisiCalc!?

Main

A function named Main that returns the main view of your application. View functions are required to return JSX (see JSX note below).

╭──────────╮
│ <Main /> │
╰──────────╯
Main() =>
  <Column>
    <>Hello to all!</>
    {@allUsers.map(
      fn(user) => <User {user} />
    )}
  </Column>

Actions

Actions are emitted by Views to modify state. Actions have their own sigil, &name, to identify them in components. Inside of an action, you can perform mutating operations like = assignment, push onto an array, or change an object property.

Internally, actions are compiled into change operations which only update the UI according to which components would be affected by that change.

╭──────────╮
│ &Actions │
╰──────────╯
fn &complete(todo: Todo) => todo.isComplete = true

-- increase the grade of a student
fn &graduate(#student-needle: Student) => &[
    @students = @students.map(
      if(student-haystack == student-needle) {
      then:
        { ...student-needle, grade: student-needle.grade + 1}
      else:
        student-haystack
      }
    )
  ]

-- this "replace needle in haystack" is common enough that it has its own helper:
&graduate =
  action(#student-needle: Student) =>
    &.replaceInArray(
      @students,
      find: student-needle,
      -- if the "needle" is found, it will be passed to the replace function:
      with: fn(student) => { ...student, grade: student.grade + 1}
    )
    -- there's also a version for dicts, &.replaceInDict(dict, find:, with:)

&updateName =
  action(#user-needle: User, name: String) =>
    if(user-needle is Student and @students.includes(user-needle)) {
    then:
      &.replaceInArray(@students, find: user-needle, with: fn(user) => { ...user, name: })
    else:
      &.replaceInArray(@users, find: user-needle, with: fn(user) => { ...user, name: })
    }

-- in components, they look like this:
<Button onPress={&graduate(@selected-student)} title="Graduate" />
<Input onChange={fn(text) => &updateName(@selected-user, text:)} … />

System Actions

TODO: finish this list

State

  • &.set(@state, value)

Array

  • &.push(@array, item) / &.queue(@array, item)
  • &.insert(@array, at: Int)
  • &.remove(@array, at: Int)

Dict

  • &.assign(@dict, item, key: String)
  • &.unassign(@dict, key: String)

Views

Views are functions that return other Views. Views must be capitalized (except system views), and cannot have positional arguments, only named arguments. The special argument children: [View] is optional, but if it is present it must be of type [View].

╭─────────╮
│ <Views> │
╰─────────╯
User(user: User) =>
  <>userSummary(user:)</>

And like Actions (&action), Slots (@slot) and Formulas (@formula), Views are always indicated with JSX-style markup e.g. <View /> or <View>{…children…}</View>.

Helpers

Lastly, any functions that you want to define for use in views and formulas. These must be lowercase, and don’t use any prefix.

╭───────────╮
│ Helpers() │
╰───────────╯
userSummary(user: User) =>
  if(user is Student) {
  then:
    `${user.name} in grade ${user.grade}`
  else:
    user.name
  }

greet(name: String) =>
  'Hi, ' ++ name

Section Header Formal Rules

The rules for defining the sections are:

  • Each section header must be present (even if empty), and in the following order: Types, State, Main, Actions, Views, Helpers
  • Each header must start with on the first line, and the second line must start with followed by whitespace, then the appropriate section title
  • You can have optional extra lines, each one must start with
  • The last line of the section header must start with
╭───────╮
│ Typesyou can put comments here.
╰───────╯

╭───────
│ State
│
│and in here, if you start with '│'
│
╰───────

╭──────╮
│ Main │
╰──────┼─────┒
       │ :-) ┃
       ╰─────┚

╭──────────┬─────────────────────────────────╮
│ &ActionsThe rules are pretty loose, imo │
╰──────────┴─────────────────────────────────╯

╭─────────╮
│ <Views> ├╼╸Be expressive!
╰─────────╯

╭ - - - - -Helpers()
╰ - - - - -

I know, this is an unfamiliar design… and all the noise this generates on Twitter will only contribute to Extra’s success! So please please please complain about or praise this decision.

Language Design

Let

let is how you can assign values to local ~variables~ scope.

let
  useful = 42
  some-thing = fn(answer: Int): String => `The answer is $answer`
in
  some-thing(really = useful)

Basic Types

Null

null

Don’t Panic! Null safety is built-in, and “calling method on null” is prevented by the compiler (if it’s not, open an issue!)

Booleans

true and false

Truthiness and the Conditional type

I went back and forth on having “truthy” types. Most functional languages are strict about what goes in an if() expression - only Boolean is allowed.

But this makes the and and or operators much less useful as short-circuiting operations. For instance, imagine you want to provide a default error message:

let
  message = error.message or "Try that again please"
in

I think the intention above is clear - and the below is no less clear, but at the expense of a ton of boilerplate.

let
  message = if(not error.message.isEmpty()) {
    then:
      error.message
    else:
      "Try that again please"
  }

And so, Extra has “Truthiness”, and we take a page from Python: anything “empty” is considered false.

null         -- the null value
false        -- the false value
0            -- the number 0
""           -- empty String
[], Dict(), Set() -- empty array, dict, set
{}, {[]}     --   "   object, tuple
1/0          -- NaN --> false… I guess? I dunno! What would **you** do with this dumb value!?

That leaves everything else as “truthy”:

true  -- the true value
1     -- any number != 0
"any" -- any String that isn't ''
[0], [a: ""], {""}, {foo: ""} Set(0)  -- any non-empty array/dict/object/tuple/et

Exception: Views and Class instances (including Regex) are always truthy, and so it is considered a compile-time error to use them as a truthy value.

Numbers

1, 2, 0x10, -0b1001, 4e2, 1__000_000 —> Int 1.0, 2., -0.000_001, 4e-2 —> Float

Supported number prefixes for other bases

  • 0x —> hexadecimal (not 0X)
  • 0o —> octal (not 0O)
  • 0b —> binary (not 0B)

TODO: Dozenal.

Supported formats

  • any number of _ are ignored 1_000 —> 1000 1___000 —> 1000 0b_1111_0000 —> 240

  • Scientific notation “m e p” is supported: 42e4 —> 42 * 10 4 = 420,000 6.022e23 —> 6.022 * 10 ** 23

If you’re thinking “wow these are all supported by JavaScript’s Number() constructor” then you’ve figured out what language this is all built in, without noticing the two dozen JS config files in project root.

Strings

Strings come in a few variants: single-quoted, double-quoted and backticks. Each variant also supports triple-quotes ('''test''').

All strings can be spread across multiple lines, though I recommend triple-quotes for that. Triple quotes have the added feature of removing preceding indentation, up to the closing quotes (more below).

Single-quoted do not support String interpolation (${}).

'testing'      --> testing
'$money'       --> $money
'test1\ntest2' --> test1
                   test2

'test1
test2' --> test1
           test2

Double-quoted strings: Same as single-quoted, but support String interpolation. Backticks: An alternative to double-quoted, also supports String interpolation.

"testing"           --> testing
"$money"            --> replaces $money with the contents of `money` reference
"${money.currency}" --> replaces ${…} with the contents of `money.currency` reference
`${money.currency}` --> same
`$money.currency`   --> replaces $money with `money`, but leaves ".currency"
`\$` / "\$"         --> If you need a dollar sign
"$123"              --> "$123", no need to escape in this case.

Triple quoted strings ignore the first character if it is a newline, and remove the preceding indentation according to the indentation of the closing quotes.

If you want to remove the trailing newline, escape it with \\.

let
  something-cool: '''
            this is a String
            right?
            ''' --> "this is a String\nright?\n"
inlet
  something-cool: '''
            remove-trailing-newline\
            ''' --> "remove-trailing-newline"
in …
'''
test1
test2
''' --> test1
        test2
-- ie "test1\ntest2\n" *not* "\ntest1\ntest2\n"

-- this can also be written:
'''test1
test2
''' --> test1
        test2

-- And because of the indent rule, this is also the same String:
'''test1
   test2
   ''' --> test1
          test2

"""
multiline
strings
are
neat
"""
-- backticks also support triple quotes, but putting triple-backticks in a markdown document? no thank you!

All strings use backslash to escape special characters:

\n --> newline (\x0A)
\t --> tab     (\x09)
\0 --> NUL/␀   (\x00)
\e --> ESC/␛   (\x1b)
\xNN --> 2 digit hex char
\uNNNN --> 4 digit hex char

-- are these characters really relevant? who uses _vertical tab_!?
\r --> silly char   (\x0D)
\v --> vertical tab (\x0B)
\f --> form feed    (\x0C)
\b --> backspace    (\x08)

-- All other backslash+char combinations return the char, even if the character
-- doesn't have any special signifigance.
-- eg
\\ --> \
\' --> '
\` --> `
\) --> )
\$ --> $

Regular Expressions / Regex

/\b(regular expressions)\b/g  <-- classic perl style regex
/\b(\$\)\b/g
/[abc]/g --> global flag
/[abc]/i --> case-insensitive
/[abc]/m --> multiline match
/[abc]/s --> dot-all match

/\b\d+\D\s/ --> the usual regex features.

Extra runs within the JS runtime, and the regular expressions are passed directly to the RegExp constructor. The Mozilla Regex cheat sheet has lots of good information about what’s supported. Say what you will about JS’s terrible API (thank you, I will!), the Regular Expressions support is very good.

Container Types: Array, Dict, Set, and Object

Arrays and Objects are created using the common [] and {} symbols. Dicts (aka Map in JavaScript) are created using dict<type>(key: value) and Sets are created using set<type>(value) (type is optional in both cases, it is usually inferred).

Keys in Objects and Dicts can be strings, numbers, null, true, or false (i.e. any primitive value).

Objects play double duty as the Tuple type, because they can have positional properties as well as named.

type User = {
  #name: String -- positional
  age: Int(>=0) -- named
}

a: User = {'Chuck', age: 50}

Homogenous types: Array, Dict, Set

Homogenous types have only one type, even if that type is an optional or oneOf type.

Array: a list of homogenous items, indexed by number. Dict: a lookup/map/hashmap of homogenous items. Set: an unordered collection of homogenous items. Only one of each item will be included in the set (according to deep equality checks).

Syntax:

  • Array: [] [] [1] [1,] [1, 2, 3]
  • Dict: Dict() Dict(key: 1) Dict(key: 1,) Dict(1: 1, 'key2': 2, "key$three": 3) Shorthand: Dict(foo) —> Dict(foo: foo), e.g. key is "foo" and value is whatever is in the reference foo
  • Set: Set() Set(1) Set(1,) Set(1, 2, 3)

Heterogenous types: Tuple, Object

Tuple: a list of items with different types. Object: a lookup/map/hashmap of different properties.

Syntax:

  • Object: {} {} {one: 1} {one: 1,} {1: 1, 'two': "two", "$three": [3]} Shorthand: {foo} —> {foo: foo}, e.g. key is "foo" and value is whatever is in the reference foo
  • Tuple: {[]} {[1]} {[1,]} {[1,"two",[3]]}
Index-byHomogeneousHeterogenous
NumberArrayTuple
StringDictObject
Homogenous
All items must have the same type
Heterogenous
All items may have different types

Syntax:

-- Arrays
[1, 2, 3]        --> [Int] ("Int array") with three entries
[]               --> empty array ([any])
["one", "two", ] --> [String] with two entries (trailing comma is ok)

-- Dicts
Dict(one: 1, two: 2, three: 3) --> Dict(Int) (Int dict) with three entries
Dict()                         --> empty dict (Dict(any))
Dict(number1: "one", number-two: "two", ) --> Dict(String) with two entries
✘ {}  --> empty object, not a dict!

-- Tuples
{[1, "two", [3,4,5]]} --> {[Int, String, [Int]]} (3-tuple of Int, String, Int array)
{[]}                 --> empty tuple
✘ {}  --> empty object, not a tuple!

-- Objects
{ age: 1, name: 'foo' } --> {age: Int, name: String}
{}                      --> empty object

-- Sets
{[ 1, 2 ]}    --> Set(Int) (Set of Ints)
{[]}          --> empty set

Objects and Dicts support a shorthand syntax if the reference name is the same as the key:

let
  foo: 'hello'
  bar: 1
in
  { foo: foo } --> {foo: String}
  { foo }     --> {foo: String}
  { foo , bar }     --> {foo: String, bar: Float}

-- dict uses the special '@' sigil
Dict( foo: foo ) --> Dict(String)
Dict( foo )     --> Dict(String)
Dict( bar )     --> Dict(Int)

Splat operators

All of the container types (Array, Tuple, Object, Dict, and Set) use the ... unary operator to merge multiple arrays/tuples into one. Arrays and Tuples can’t be mixed and matched, though.

-- arrays
let
  a: [1, 2, 3]
  b: [4, 5, 6]
in [...a, ...b] --> [1, 2, 3, 4, 5, 6]

-- tuples
let
  a: {[1, "2", 3]}
  b: {[4, "5", 6]}
in {[...a, ...b]} --> {[1, "2", 3, 4, "5", 6]}

-- objects
let
  a: {a: 1, b: "2", c: 3}
  b: {d: 4, e: "5", f: 6}
in Dict(...a, ...b) --> {a: 1, b: "2", c: 3, d: 4, e: "5", f: 6}

-- dicts
let
  a: Dict(a: 1, b: 2, c: 3)
  b: Dict(d: 4, e: 5, f: 6)
in Dict(...a, ...b) --> Dict(a: 1, b: "2", c: 3, d: 4, e: "5", f: 6)

-- sets
let
  a: Set(1, 2, 3)
  b: Set(3, 4, 5)
in Set(...a, ...b) --> Set(1, 2, 3, 4, 5)

Objects and Tuples can contain values with different types (this is called a Product Type). What happens if you put different types into an array or dict?

[1, 2, "3"] -- Invalid!? Nope! This has the type [Int | String]

Enter the OneOf type.

OneOf

OneOf types represent a value that could be one type or another (or three or four types).

The most common is called the optional type, which is any type T or the null value. But you may also need to store a value that is either of type Int or a String (input that is either “raw” (String) or already processed into an Int, for example).

OneOf types can be expressed in general as type1 | type2 | ..., e.g. Int | String or [String] | null. The optional type has a shorthand Int? --> Int | null.

[ 1, 2, null]  --> [Int | null] aka [Int?]
[ 1, 2, "age"]  --> [Int | String]

The only problem with oneOf types is that you cannot call methods or properties on them, unless the method is shared between both types. You can get around this limitation using type guards (or other type assertions).

Literal types

So far we’ve been expressing numbers and strings using their types, but literal types are also supported. For instance, the expression:

1 + 2

Is parsed as literal(1) + literal(2), and resolved to the type literal(3). You can express enumerations this way, too:

size: 'small' | 'medium' | 'large' --> size must be one of these strings, no others.

Type definitions

We’ve seen many definitions already.

  • null true false some literal value types
  • 1 1_000 'text' also literal types
  • Boolean Int Float String the basic types
  • Boolean | Int one of types
  • [Int] [Int | String] [Float?] arrays
  • Dict(Int) Dict(Int | String) Dict(Float?) dicts
  • {Int, String} {Int?, String?} tuples
  • {foo: Int, bar: String} {foo: Int?, bar: String?} objects
  • [Boolean] | (Int | String)[] one of types mixed with container types

if

if is implemented internally by the compiler, but I made sure that the syntax was supported by the user-defined functions. Combined with lazy you can create some pretty sophsiticated DSLs. At least, that was the intention.

if(test1 or test2) {
then:
  result_1
elseif(test2):
  result_2
else:
  if(test3, then: result-3, else: result-4)
}

As in all function programming languages, if is an expression that returns the value of the branch that was executed. If else is not given, null is returned.

Operators

Comparison

a > b
a >= b
a < b
a <= b
a == b  --> does a deep comparison of objects/arrays/dicts/etc
a != b

a <=> b --> the sort operator compares strings and numbers, and returns -1, 0, or 1

Basic Math

1 + 2    --> 3    Addition
15 - 2   --> 13   Subtraction
8 * 2    --> 16   Multiplication
10 / 5   --> 2    Division
10 / 6   --> 1.6… Division returns a Float *even if* you provide two Ints, see // below
2 ** 8   --> 256  Power/exponent

CompSci Math

-- Integer/floor division removes the floating point "remainder" by flooring the
-- result. When dividing negative numbers, it always rounds down (not towards
-- zero).
15 // 2  --> 7
-10 // 3 --> -4

10 % 3 --> 1  Modulo / Remainder, also works with floats

-- Binary Operators
0b100 | 0b001  --> 0b101 (5)
0b110 & 0b010  --> 0b010 (2)
0b110 ^ 0b010  --> 0b100 (4)

-- Let's talk about binary negate:
~4         -->    0b11
~0b100 (4) -->   0b011
~0b0100    -->  0b1011 !?

-- binary, octal, and hexadecimal numbers store their "magnitude", which is then
-- used by the `~` operator to determine how many digits to flip. @-me in the
-- comments.

 ~0xa0a -->  0x5f5
~0x0a0a --> 0xf5f5

Logical Operators

Logical operators “short circuit”, e.g. they return values without converting them to a Boolean.

a or b  --> Logical Or, returns `a` if a is true, otherwise returns b
a and b  --> Logical And, returns `b` if a is true, otherwise returns a

-- Examples
a = 5
b = 0
c = 1

a and c --> 1 (returns c, because a was true)
a or c --> 5 (returns a, because a was true)

b and a --> 0 (returns b, because b was false)
b or a --> 5 (returns a, because b was false)

Btw, if you think of and as “multiplication” (if either is 0/false, result is 0/false) and or as “addition” (if either is 1/true, result is 1/true) you’ll have an easier time remembering the order of operations (and first, then or)

Regex Match Operator

"test String" ~ /[test]/ --> Boolean, returns whether the test String matches

Null Coalescing Operator

Included only because of its cool name. 😎

a ?? b --> returns `b` if a is null, otherwise returns `a`

Concatenation

I’ve never liked + as String/array concatenation. + should be communative, because maths. Also because then String coersion becomes obvious.

"aaa" ++ "BBB" --> "aaaBBB"

12345 ++ 'dollars'  --> "12345 dollars"
`${12345} dollars`  --> "12345 dollars"

-- if you want locale-specific formatting:
12345.toLocale('en-ca') ++ " dollars" --> "12,345 dollars"

Array/Dict/Tuple/Object Access / Property Access

Property access looks like you’d expect object.property, and works on objects and dicts. [] works on all container types (object, dict, tuple, array), and accepts expressions (e.g. object["foo"] --> object.foo or array[1 + 1] --> array[2]).

An important difference with property access and array access is that property access will prefer built-in properties, whereas array access will always search for the value in the table. For example, Dict defines map and mapEntries methods, and so things.map will call that function. But things["map"] will ignore the built-in function and instead search for an entry named map and return that (or null - it will not return the map function).

If the property access isn’t a build-in, it will search for that property in the Dict/Object. So things.foo == things['foo']. These will return T | null unless the key is known to be in the dict/object:

let
  values: Array(Int) = [1,2,3]
in
  values[2]  --> 3


let
  ages: Dict(Int) = [alice: 50, bob: 46, map: 10]
in
  ages['alice']  --> 50
  ages.bob       --> 46
  ages.map       --> `map` function, which iterates over the values
  ages['map']    --> 10

-- null safe, ie if person could be null
person?.address.street ?? 'default address'  --> returns person.address.street if person is defined, otherwise returns 'default address' due to null coalescing operator

Pipe Operator

Everyone’s favourite! Well it’s my favourite, and if you haven’t used it today’s your day. It’s more likely that you’ve used chained methods – the pipe operator is a natural companion, but in cases where a chained method isn’t an option. Here’s an example that surrounds a stringified array with "[]" characters, and adds a trailing comma if the array wasn’t empty.

[1,2,3].filter(fn(i) => i < 3).join(',')
  |>
    if(#.length) {
    then:
      $# ++ ','
    else
      ''
    }
  |>
    `[$#]`  --> `"[1,2,3,]"`

There’s also a null-safe version:

-- name is String | null
name ?|> name ++ ':' --> inside the pipe `name` is guaranteed to be a String, otherwise the expression is skipped and `null` is returned.

There’s a clever trick with the => operator that allows us to “name” the # symbol. This is eerily similar to fn() =>, so be mindful that we are using the match feature of Extra here, not function invocation.

[1,2,3].filter(fn(i) => i < 3).join(',')
  |> numbers =>
    if(numbers.length) {
    then:
      $numbers ++ ','
    else:
      ''
    }
  |> numbers => `[$numbers]`  --> `"[1,2,3,]"`

JSX-ish

What!? Should I call it something else just because it is something else? Bah. It walks like a duck and quacks like a duck, so I’m calling it JSX.

Similarties:

  • Within a text node, {…} encloses an expression that is inserted as a child.

    <Foo>Name: {@user.name}</Foo>
    <Foo>Item 1: {if(foo) { then: <Item1 />, else: <Item 2/>}}</Foo>

The differences from React JSX:

  • attributes can receive extra values, so <Foo prop=bar /> assigns the variable bar to prop There are limitations to this, though: you cannot use most binary operators, only ‘access’ operators like . and []. You can always enclose operations in ().

    <Foo prop=1 + 1 /> is invalid. <Foo prop=(1 + 1) /> is fine.

    {} is, like everywhere else in Extra, for creating objects.

    <Foo prop={a: 1, b: "two"} />
  • shorthand for boolean isSomething has corresponding !isSomething shorthand.

-- In React-JSX, boolean properties are either "bare" (`isNifty` in this example), or given the values `true|false`.
<Test isNifty isGreat={true} isTerrible={false} />

-- In Extra-JSX you can use `isNifty` like in JSX, or negate a property using `!isTerrible`
-- and, since expressions are supported, you don't enclose `true|false` in curly braces.
<Test isNifty isGreat=true !isTerrible />
[G] Extra