250 lines
6.9 KiB
Markdown
250 lines
6.9 KiB
Markdown
# 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:
|
|
```solace
|
|
fn sum(a: number, b?: number): number {
|
|
if b is undefined {
|
|
return a
|
|
}
|
|
return a + b
|
|
}
|
|
```
|
|
|
|
// But also supports Optionals on a syntax level:
|
|
```solace
|
|
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:
|
|
```solace
|
|
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:
|
|
```solace
|
|
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:
|
|
```solace
|
|
// 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:
|
|
```solace
|
|
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:
|
|
|
|
```solace
|
|
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:
|
|
```solace
|
|
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.
|
|
```solace
|
|
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:
|
|
```solace
|
|
fn twentyIfTrue(x: boolean) => (value: ?number = none) {
|
|
if x: value = 20
|
|
}
|
|
```
|
|
|
|
Now to the more interesting part: Reactivity and unified handling of values.
|
|
```solace
|
|
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:
|
|
```solace
|
|
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:
|
|
|
|
```solace
|
|
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++)
|
|
}
|
|
```
|