Creating tools for Developers

DATE 2026-02-14 -- TIME 20:07:57

Description

Extra's Extra Features // Extra

In no particular order, I’d like to share a bit about some of the fun Extra features of Extra.

Refined types

I already talked about these!, but I want to mention them again because I think Extra provides a really aesthetic solution to a very common data modeling problem: functions that require N items in order to do their work. My canonical example is a function that expects an IP address.

type IPv4 = Array(Int(0...0xff), length: =4)
type IPv6 = Array(Int(0...0xffff), length: =8)
type IP = IPv4 | IPv6

Tuples and algebraic data types will get you close, sure, but Int in the range 0 to 255 as a type is just wonderful.

Extra’s Operators are Friggin’ Sweet

Math is for math

+ is for numbers. Why anyone thought it was appropriate to “add” strings and arrays is beyond me. Read a book! (about math)

+ is also for Set-s. Oh yeah right well this also makes sense, numbers and sets can be added transitively that’s what math is

Distinct concat/merge operators

  • <> is for concatenating two String-s.
  • ++ is for concatenating two Array-s. I could’ve made these one operator… actually I did at one point! But having two provides more local context and makes the code more intentional.
  • ~~ is for merging Object-s and Dict-s, and here the order really matters because values on the RHS will overwrite values on the LHS.

Isn’t it nice that they’re all double characters? So consistent. It’s these little things that justify my arrogance and enhance my charisma.

Matches

Regex matches are done w/ a keyword: string is /regex/. This was the ~= operator at one point - or was it =~? And since I couldn’t remember then I can’t expect you to either so here we are, and again you’re welcome.

Actually a lot of things can be done with the is keyword:

foo is {a, b, null} would be true (and assign a and b) if foo is a 3-tuple with null as its third value.

Booleans

Boolean operators are also words (and or not) however I wanted Extra to be easy to write, so the C-style && || ! also work and no they don’t have different precedences (glares at Ruby.. then sees Matz and smiles and gives a thumbs up, wow what a nice guy I hope he didn’t see me glare)

Null safe

Null coalescing ?? is a null-coalescing pipe operator! Like Maybe.map with 2 less words and 2 more symbols. And also ?|>, which executes the rhs only if the lhs is not null.

Monads and map family of functions

If I want to look cool in front of the Haskell nerds (I do) I have to mention monads. But to look cool in front of the TypeScript nerds, who couldn’t tell the difference between a monad and an algebraic data type to save their life, I will tell you something that is only kind of correct, but you’ll sound smart repeating it:

Monads are just boxes for other values. Arrays have lots of values, Option/Maybe has 0 or 1, Result has a value that is one type or another. These are all monads! Read the blog below if you want more accurate details, but this is close enough for me to present another idea: values as their own monad. Define a map function on all values, and you get interesting behavior when it comes to the Option type.

(Read A Gentle Introduction to Monads for more details).

let
  a: Int = 5
  b: Int? = 5
  c: Int? = null
  d: Array(Int) = [5]
in
  {
    a.map(fn(x) => x + 1) --> 6    -- Int
    b.map(fn(x) => x + 1) --> 6    -- Int?
    c.map(fn(x) => x + 1) --> null -- Int?
    d.map(fn(x) => x + 1) --> [6]  -- Array(Int)
  }

map, flatMap, and compactMap functions are defined on all values. map is a straightforward 1:1 mapping function on all containers, and all “atomic” values, like numbers and strings. Except null, where no matter what function you throw at it, it returns null. This makes map on optional types functionally equivalent to the ?|> null-coalescing pipe operator.

Other map functions are also defined, but have different behavior:

  • flatMap expects the same Monad type as return value. Set(T).flatMap return Set(U), Array(T) return Array(U), etc. It’s a way to increase or decrease the number of values in a container.
  • filterMap expects an optional return type. null is filtered, all other values are kept. If you need to keep null but filter something else, you can either return .some(null) or use flatMap.
  • These methods behave differently on container types and optionals, but on value types (Int, String, etc) these all behave identically as map.
  • Worth noting that for the sake of these functions, Object is a value type, not a container type.

Ranges

Range operators. We need to sit down and agree that I got it right, because it’s absurd that there are languages that expect us to memorize the difference between .. and ... (one of those is inclusive!).

1...10 1<..10 1..<10 1<.<10 - three characters each, inclusive and exclusive options on both sides.

I won’t insult you by pointing out which are inclusive and which are exclusive because it’s obvious. (and don’t worry .. is not an operator and so 1..10 isn’t valid)

Type checks

There is also is for type checking and has for array/dict key checking. Likewise !is and !has, by the way. Why not not is and not has, to match the logical not operator? I don’t remember, and I don’t think I’ll change it.

Conditional operators

There is, of course, an if expression, but there is also an inclusion operator for arrays/sets/dicts/objects. Another Extra Original™ (as far as I know… and I asked Claude and he agreed so it must be true).

if condition then expr1 else expr2 is the if expression. else if to chain multiple checks.

if condition -- newline is just as good as 'then'
  expr1
else if condition2
  expr2
else
  expr3

The inclusion operator can be accomplished using splat operators in JavaScript:

[1, 2, 3, ...(condition ? [x] : [])]

But can we do better?

[
  1
  2
  3
  x onlyif x > 3
  ...items onlyif includeItems
]

I went back and forth on whether to include a ternary operator… if I change my mind, I know that I will implement it using then/else and not ?/:, but I find that ternaries inevitably lead to unreadable code, so I am not inclined to add them at the moment.

Default value ”#default

This has come up many times for me, and yet I’ve never seen another programming language offer up this feature (I’m probably wrong, please leave a comment below if you know of one (I’m kidding I don’t have comments, I’m the only troll allowed to comment on this site thank you very much)).

Let’s imagine we are consuming an API that has the function signature below. We know that it provides default values, and maybe we could go look those up, but this is an external library, and so we want to treat those default values as black-box implementation details.

fn addSomeNumbers(# a: Int = XXX, # b: Int = YYY) => a + b

We are, like very good 1st year CompSci students, going to wrap this in a local abstraction layer.

import API only { addSomeNumbers }

pub fn addEmUpPlusOne(# a: Int = XXX, # b: Int = YYY) =>
  1 + addSomeNumbers(a, b)

What should we use for those values? Well, if we make them null-able, we could tediously check for that and call the function according to whether the value was provided:

pub fn addEmUpPlusOne(# a?: Int, # b?: Int) =>
  1 + (switch {a, b}
    case {null, null}
      addSomeNumbers()
    case {a, null}
      addSomeNumbers(a)
    case {null, b}
      addSomeNumbers()  -- ah, hell
    case {a, b}
      addSomeNumbers(a, b)

Not only is this garbage, we can’t even do it, due to the inability to call addSomeNumbers and specify the second but not the first argument (arguably this is a limitation of Extra, because maybe we should be able to call the function this way).

Enter the “default value #default”. Many reserved words are prefixed by #, which namespaces these so that I can introduce future macros or special features while preserving backwards compatibility.

pub fn addEmUpPlusOne(# a?: Int, # b?: Int) =>
  1 + addSomeNumbers(a ?? #default, b ?? #default)
  -- P.S. above, we could've finished our garbage version with
  -- `addSomeNumbers(#default, b)`. I was just joking when I said extra
  -- couldn't handle this 😏

#default means “use whatever the default value is, but I don’t want to specify it because that would break Demeter’s law and while she seemed like one of the nice Gods, I’m not going to roll those dice.”

Pipe Operator

I just love the pipe operator, and while I appreciate how Elixir and Elm implemented it, I think Hack actually nailed it (I know right? When’s the last time you heard someone mention Hack, and were they praising it? I doubt it).

The |> pipe operator takes the left-hand-side and makes it available to the rhs, usually in the context of calling a function. But what Hack does is it assigns the lhs to a sigil that you can use in the rhs. I chose #pipe for the name. Actually I chose # but later I changed it when I introduced more macro-like features (#default, #line, etc).

fullname
|> #pipe.split(' ')  -- String -> Array(String)
|> #pipe.map(fn(name) => name.length)  -- Array(String) -> Array(Int)
|> sum(...#pipe)  -- Array(Int) -> Int
|> `$fullname, you have $#pipe letters in your name`

-- hack uses '$$' as the sigil for the pipe value, so I guess they didn't *quite* nail it...

Really Beautiful Comments

You’re going to roll your eyes but I don’t care. Extra tosses their hair back over their shoulder and says Express Yourself! Don’t write comments… DRAW comments!

-- this is a comment
--> so is this, obviously
<-- but so is THIS, because pointing at things is handy (and RUDE)
{- block comments {- and yes they can be nested -} -}

-- YAWN's-ville so far

╓                        ╖
║ Now THIS is a comment! ║
╙                        ╜

All Unicode box-drawing characters are ALSO comment sigils. I’m a monster, I know, but now you’re hopefully starting to get a taste of that “Extra” flair.

Wait...*this* is a comment?Sure!oh and also thisand this
↔︎ but not this because I had to draw the line somewhere.

Function arguments

I went all in on having expressive function arguments. Positional, named, variadic, and beyond.

Positional Arguments

You can’t be Extra if you’re not also Basic.

fn print(# text: String) => System.Console.Stdout.write(text .. "\n") -- Suck it Java

print("hello")

Positional arguments require the # prefix. I wanted to encourage named arguments, but still allow for positional arguments when the argument name isn’t necessary.

Named Arguments

Now we’re getting somewhere:

fn surround(pre: String = '', post: String = '') =>
  fn(# text: String) => pre .. text .. post

let
  htmlify = surround(pre: '<', post: '>')
  -- also surround(post: '>', pre: '<')
in
  htmlify('a') --> '<a>'

Named arguments can be passed in any order, and can come before or after positional arguments. Don’t get too excited, though, the code formatter rearranges them to be in the order specified by the function.

Spread Positional Arguments

This is when you want “and all the rest are BLAH”. They have to be an Array of the same type:

fn sum(...# numbers: Array(Int)) => numbers.reduce(fn(memo, value) => memo + value, 0)

sum() --> 0
sum(10) --> 10
sum(10, 32) --> 42

Spread Named Arguments WHaaaaat!?

A combination of positional-named-arguments and named-arguments. The name is repeated:

fn math(# number, ...plus: Array(Int) = [], ...minus: Array(Int) = [])
  =>
    number
    + plus.reduce(fn(memo, value) => memo + value, 0)
    - minus.reduce(fn(memo, value) => memo + value, 0)

math(8, plus: 2, minus: 5, plus: 10, plus: 2) --> umm, it's uh... 15... no... 17!

Keyword arguments

Lastly, any named arguments you didn’t specify in the args-list can be put into a Dict (kwargs in Python-speak)

fn greet(*names: Dict(String))
  => -- it's getting late, so please imagine a function implementation here

greet(colin: 'hello', alice: 'welcome', bob: 'sure come in I guess')
--> 'hello, colin. welcome, alice. sure come in I guess, bob'

Curry-able

Maybe it would be cooler if this was automatic… but I’ll take “possible and explicit” over automatic most days. curry is a member function that accepts some (or all) of the arguments of the original function, and returns a function that expects the rest.

fn surround(# text: String, pre: String = '', post: String = '') =>
  pre .. text .. post

let
  htmlify = surround.curry(pre: '<', post: '>')
  -- htmlify: fn(# text: String): String
in
  htmlify('🥹') --> '<🥹>'

String literals are 🔥🔥🔥🔥🔥

Let’s crack those knuckles and get the basics out of the way:

-- single quotes are simple, but do support escape sequences
'yeah wow\nthat IS $money'  -- just how it looks (\n is a newline)
-- as of 2025-04-15 the '$' is being treated as a sigil by the code highlighter,
-- but I don't know why

-- double quotes support string interpolation
"You have $time remaining seconds"
-- as of 2025-04-15 the code colourization isn't detecting $time as an
-- interpolated string

-- backtick is an alternative to double-quoted, because I love all my
-- quote-looking characters
`I'm a backtick ${'string'}`

Sure, fine so far, but where’s the EXTRA!?

-- triple quoted variants
'''now that's a spicy meatball'''

-- if you put a newline after the triple quote, it will be *ignored*
'''
now that's a spicy meatball'''

-- indentation will also be ignored!
'''
    now that's a
    spicy meatball'''
==
'''
now that's a
spicy meatball'''

-- the final newline isn't ignored
'''
now that's a
spicy meatball
''' --> ends in a newline

-- unless you escape it with ''
'''
now that's a
spicy meatball''' --> ends in a meatball

Um what else, variable names I guess?

If it’s not a box-drawing character, and not an operator, I guess it’s a variable name? Also they can be hyphenated.

let
  guess-my-age = 37  -- not true; copilot offered this up. what a flirt
  🤓 = '😎'
in

string.length is pretty much what you expect - how many glyphs.

There are also ways to count code points or to request a specific encoding, but asking “how many letters is this” is the thing that is easiest.

"🤯👩🏽‍🏫".length --> 2

Commas are optional

Oh man I really buried the lede on this one. This is so cool!

In all cases where commas delineate items (arrays, imports, function arguments), they are optional if you use newlines instead.

The rule for “will it be considered a comma” is “is the next thing an operator?”

The one exception is “is the next thing a negative number”.

import Http only {
  get
  post
}

[
  1
  2
  -3
  4
  + 5
] --> [1, 2, -3, 9]

sum(
  1
  2
  3
  4
) --> 10

That’s a lot of Extra and I kid you not I got sleepy before I ran out of cool stuff to talk about.

See you next time!

Published: 2025-03-13