A programming language that compiles to JavaScript
Find a file
2025-06-21 08:57:57 +02:00
src initial pest grammar and parser 2025-06-21 08:57:57 +02:00
.gitignore initial pest grammar and parser 2025-06-21 08:57:57 +02:00
Cargo.lock initial pest grammar and parser 2025-06-21 08:57:57 +02:00
Cargo.toml initial pest grammar and parser 2025-06-21 08:57:57 +02:00
README.md initial pest grammar and parser 2025-06-21 08:57:57 +02:00
test.solace initial pest grammar and parser 2025-06-21 08:57:57 +02:00

Solace

A language to find solace after using JavaScript

Proposal

A strongly typed programming language that compiles to JavaScript and comes with the following features:

  • Error unions: Error!string
  • Optional values: ?string
  • scoped blocks
  • exhaustive pattern matching
  • "unreachable" as keyword
  • defer statements
  • built-in reactivity system (using signals?)

Syntax

Solace looks like Zig, but without the low-level complexities and added pattern matching. Typical expressions are very much like in TypeScript and it supports the same primitive types undefined, string, number, boolean, and their array versions, as well as Map and Set. It uses none instead of null and objects are called structs. There is no "new" keyword and there are no classes. Instead of classes, Solace uses scopes that work just like JavaScript's scopes.

It also looks very similar to TypeScript, if you want:

fn sum(a: number, b?: number): number {
  if b is undefined {
    return a
  }
  return a + b
}

// But also supports Optionals on a syntax level:

fn sum(a: number, b: ?number): number {
  if b is none { // here b has to be defined, but can be set to none
    return a
  }
  return a + b
}

// And Error unions:

fn sum(a: number, b?: number): !number {
  if b is undefined {
    return failure("Second number is missing")
  }
  return success(a + b)
}

Error unions default to Error, so the last example is equivalent to this one:

fn sum(a: number, b?: number): Error!number {
  if b is undefined {
    return failure("Second number is missing", Error)
  }
  return a + b
}

A more specific error might be needed, though. Although Solace doesn't really use classes, it can handle inheritance:

// The following lines compile to:
// function NumberError(message) {
//   this.name = "NumberError"
//   this.message = message
// }
// NumberError.prototype = Error
fn NumberError(message: string) extends Error {
  _.name = "NumberError"
  _.message = message
}

fn sum(a: number, b?: number): NumberError!number {
  if b is undefined {
    return failure("Second number is missing", Error)
  }
  return a + b
}

Solace also supports initialized default return values, similar to swifts inout parameters. They are really useful for short functions:

fn fizzbuzz(until: number) => (output: string[] = []) {
  // var output = [] // no need, because it is already defined
  for i in 1..=until { // this is an inclusive range
    // pattern matching
    match i {
      _ % 3 == 0 && _ % 5 == 0 -> output.push("fizzbuzz")
      _ % 3 == 0 -> output.push("fizz")
      _ % 5 == 0 -> output.push("buzz")
      _ -> output.push(i.toString())
    }
  }

  // no need, because it's the default return value, but can added for clarity
  // return output
}

Matches are expressions, which means, they return a value. Also, just for completeness, match doesn't need a value. So here is the last example changed to take both into account:

fn numberToFizzBuzz(n: number): string {
  return match {
    i % 3 == 0 && i % 5 == 0 -> "fizzbuzz"
    i % 3 == 0 -> "fizz"
    i % 5 == 0 -> "buzz"
    i -> i.toString()
  }
}

fn fizzbuzz(until: number) => (output: string[] = []) {
  for i in i..=until { // an exclusive range would be i..<until
    output.push(numberToFizzBuzz(i))
  }
}

If can be used as a statement or expression:

fn twentyIfTrue(x: boolean): ?number {
  const value = if x {
    20
  } else {
    none
  }
  return value
}

Of course this could be written with less lines. Here with a named return variable.

fn twentyIfTrue(x: boolean) => (value: ?number) {
  value = if x { 20 } else { none }
  // or
  value = x ? 20 : none
  // or
  value = if x: 20
          else: none
}

An if expression always needs to cover all cases to ensure that the variable will be assigned. If statements don't have that requirement, but then the return value needs to be initialized:

fn twentyIfTrue(x: boolean) => (value: ?number = none) {
  if x: value = 20
}

Now to the more interesting part: Reactivity and unified handling of values.

import fs from "node:fs/promises"

fn openFile(path: string) {
  // fs.open returns a Promise, and while Solace supports them, it doesn't need
  // Promises or async/await, because it has live values.
  // Promises (and with them return values of async functions) are translated
  // to reactive values if used like this:
  live fd = fs.open(path, "rw")

  // This defer statements runs after the function finished, but only if the
  // given pattern matches, in this case if ok(fd) returns a value.
  defer when ok(fd) {
    // Because it is a nameless assignment, we use _ to
    // access the return value. If you need a name, use:
    // defer when ok(fd) |file| { /*...*/ }
    _.close() // close file descriptor
  }

  // This defer statement always runs after everything else finished.
  // You can see it like try-finally, for functions.
  defer {
    console.log("This message will show after the function finished.")
  }

  // live variables would be defined like this in TypeScript:
  // { state: "pending", value: Promise<T>, error: null } |
  // { state: "err", value: null, error: Error } |
  // { state: "ok", value: T, error: null }
  watch fd {
    match fd {
      // ok(x) returns the value of Result types like live, which is an Error
      // union essentially, while err(x) returns the error or null
      ok(fd) -> handleFile(_)
      err(fd) -> handleError(_)
      _ -> continue // matches need to handle all possible cases
    }
  }
}

Here is another way to write the former example:

import fs from "node:fs/promises"

fn openFile(path: string) {
  live fd = fs.open(path, "rw")

  // this defer executes only if at the end of the function ok(fd) returns a value
  defer when ok(fd) |file| {
    console.log("File closed.")
    file.close() // close file descriptor
  }

  // live variables would be defined like this in TypeScript:
  // { state: "pending", value: Promise<T>, error: null } |
  // { state: "err", value: null, error: Error } |
  // { state: "ok", value: T, error: null }
  watch fd {
    match fd {
      // ok(x) returns the value of Result types like live, which is an Error
      // union essentially, while err(x) returns the error or null
      ok(fd) -> handleFile(_)
      err(fd) -> handleError(_)
      _ -> continue // matches need to handle all possible cases
    }
  }
}

Here some more loop constructs:

const myArray: number[] = [23, 42]
for x in myArray: console.log(x)
for y,i in myArray: console.log(`Index ${i} has value ${y}!`)

for i in 0..<23 {
  console.log("This will output as long as i is smaller than 23")
}
for i in 0..=23 {
  console.log("This will output as long as i is smaller or equal than 23")
}
while i <= 23: console.log("This will output forever, because I forgot to change the value of i")

while i < 23 {
  console.log("This will stop when i reaches 22. Currently it has the value", i++)
}