Â
Rust inspired Borrow Checker, TypeScript inspired Syntax, Go inspired Concurrency
Statically Compiled Binary
Multi Threaded & Concurrent
Compiler Protection from Race Conditions
Memory Safe without Garbage Collection
Please contribute your thoughts to the design of this language specification!
Hello World
import console from '@std/console'
function main() {
const text = 'Hello World' // Dynamic string
console.log(read text) // Handing out a "read" borrow
}
CLI Usage
bsc --os linux --arch arm64 -o main main.bs
Summary
BorrowScript aims to be a language that offers a Rust inspired borrow checker with a relatively simplified syntax.
It aims to do this by offering higher level builtin types, builtin concurrency and more explanatory keywords for the borrow checker.
It's hoped that this will make using/learning a borrow checker more accessible while also offering a higher level language well suited to writing applications like desktop applications, web servers and web applications (through web assembly).
BorrowScript does not expect to match the performance of Rust, but it aims to be competitive with languages like Go - offering more consistent performance, smaller binaries and a sensible language to target client programs.
Language Design
Variable Declaration
To declare a variable you can use the keywords const
and let
, which describe immutable and mutable bindings.
let foo = 'foo' // mutable
const bar = 'bar' // immutable
Function Declarations
Functions can be defined in full or using shorthand lambda expressions
function foo() {} // immutable declaration
// Shorthand
const foo = () => {}
let bar = () => {}
Class Declaration
class Foo {
constructor() {} // Invoked when class is instantiated
destructor() {} // Invoked when class is dropped from scope
}
Types
BorrowScript contains opinionated builtin types. Where Rust would use something like:
let myString: String = String::from("Hello World");
BorrowScript uses:
const myString: string = "Hello World"
const myString = "Hello World" // type inference
All types are references to objects and can be mutated or reassigned if permitted. The types are as follows:
const s: string = ""
const n: number = 0
const b: boolean = true
const z: null = null
const a: Array<string> = []
const m: Map<string, string> = new Map()
const s: Set<string> = new Set()
Nullable types are described as:
let foo: string | null = 'foo'
let bar: string | null = null
Mutability
Mutability is defined in the binding and affects the entire value (deeply).
It's essentially a guarantee that any value assigned to the container will abide by the mutability rules defined on the binding.
Reassignment to another binding will allow values to be changed from mutable/immutable as the binding defines the mutability rules.
const foo: string = 'Hello' // immutable string assignment
let bar = foo // move the value from immutable "foo" into mutable "bar"
bar.push(' World')
Ownership
The BorrowScript compiler will handle memory allocations and de-allocations at compile time, producing a binary that does not require a runtime garbage collector. This ensures consistent and efficient performance of applications written using BorrowScript.
In order for the compiler to know when a value is ready to be released from memory, it needs some hints from the programmer.
Following in Rust's footsteps, BorrowScript uses an ownership tracker to know when a variable is no longer referenced and it's safe to release it from memory.
A secondary benefit is the compiler can know if a value is at risk of being written to from multiple threads - allowing the compiler to avoid compilation if it detects race conditions.
The "owner" of a variable is its declaration scope:
function main() {
const foo = 'Hello World' // "foo" is owned by "main"
// <-- at the end of main's block, the value in "foo" is released
// avoiding the need for a garbage collector
}
Ownership can be loaned out to another scope as read
or write
. There can either be be one scope with write
access or unlimited scopes with read
access.
An owner can move
a variable to another scope and doing so will make that value inaccessible in its original scope.
Ownership Operators
read
/write
function readFoo(read foo: string) {}
function writeFoo(write foo: string) {}
function main() {
let foo = "Hello World" // "foo" is owned by main
readFoo(read foo) // "foo" has 1/infinite read borrow
// "foo" has 0/infinite read borrow
writeFoo(write foo) // "foo" has 1/1 write borrow
// "foo" has 0/1 write borrow
// <-- "foo" is released
}
A scope with write
has read
/write
.
A scope with read
has read
only.
A scope can only lend out to another scope a permission equal or lower than the current held permission.
Note that the ownership operator can be omitted when passing a value into a function
function readFoo(read foo: string) {}
const foo = "Hello World"
readFoo(read foo)
readFoo(foo) // Infer "read" from function's call signature
move
The move
operator allows for transferal of a variable's ownership from one scope to another. This literally removes a variable from the current scope and makes it unavailable.
function moveFooDefault(foo: string) {} // Defaults to move[const]
function moveFooImmutable(move[const] foo: string) {}
function moveFooMutable(move[let] foo: string) {}
function main() {
const foo = "Hello World"
moveFooDefault(move foo)
// moveFooDefault(foo) can be omitted
}
copy
This is syntax sugar specifically for BorrowScript. It invokes the .copy()
method on an object.
The use case for this is to simplify the transferal of types through "ownership gates" which we will discuss further below
const foo = "foo"
let bar = copy foo // same as foo.copy()
bar.push('bar')
Rust Examples of Ownership Operators
Operator | BorrowScript | Rust |
---|---|---|
read |
function readFoo(read foo: string) {
console.log(foo)
} |
fn read_foo(foo: &String) {
print!("{}", foo);
} |
write |
function writeFoo(write foo: string) {
foo.push('bar')
console.log(foo)
} |
fn write_foo(foo: &mut String) {
foo.push_str("bar");
print!("{}", foo);
} |
move[const] |
function moveFoo(move[const] foo: string) {
console.log(foo)
}
function moveFoo(foo: string) {
console.log(foo)
} |
fn move_foo(foo: String) {
print!("{}", foo);
} |
move[let] |
function moveFoo(move[let] foo: string) {
foo.push('bar')
console.log(foo)
} |
fn move_foo(mut foo: String) {
foo.push(String::from("bar"));
print!("{}", foo);
} |
Ownership Gates
In Rust, callback functions do not automatically have access to the variables in their outer scope. In order to gain access to a variable from within a nested scope (callback function), you must explicitly import variables from the parent scope.
Here is a simple example of accessing outer scope in TypeScript (not BorrowScript)
const message = 'Hello World'
setTimeout(() => {
console.log(message)
})
In Rust, you have to move a value into the callback scope before using it.
let message = String::from("Hello World");
set_timeout(&(move || {
print!("{}", message);
}));
In BorrowScript we describe imports from the parent scope of a callback using ownership gates which are declared as square brackets after the function parameters:
const message = "Hello World"
setTimeout(()[move message] => { // "move" can be omitted as it's the default action
console.log(message)
})
The complete syntax looks like:
function name<T>(params)[gate]: void { }
You can also copy a value into a child scope using ownership gates. Using copy will create a shadow variable with the same name within the child scope.
This is used for moving mutexes into concurrent contexts.
setTimeout(()[copy message] => { })
Which is equivalent to:
const message = "Hello World"
const messageCopy = message.copy()
setTimeout(()[move messageCopy] => {
const message = messageCopy
console.log(message)
})
Lifetimes
Lifetimes are parameters described using the lifeof
type operator within a generic definition.
This syntax needs a bit of work probably
function longestNumber<lifeof A>(read[A] x: number, read[A] y: number): A<number> {
// return the longer number
}
What this essentially tells the compiler is to only work when both the x
and y
variables share the same lifespan. If one drops out of scope before the other (clearing it from memory) then the life times of the variables are not the same.
Concurrency
BorrowScript will use Go-like co-routines to manage task queues across multiple processors. The control flow of these concurrent tasks will be managed with the concept of a Channel
Note, BorrowScript uses async
/yield
differently to JavaScript. Think of async
like the go
keyword in the Go programming language and yield
as the <-
operator
async
Starting a concurrent task with A function is invoked concurrently by prefixing it with async
function concurrentFunc() {
console.log('Hello World')
}
async concurrentFunc
You can do this with functions defined inline
async () => {
console.log('Hello World')
}
yield
Channels and A channel will yield
a single value or values until it's closed.
Calling yield
on a channel will provide the first value emitted on it.
const channel = new Channel<string>()
async ()[copy channel] => {
const message = yield channel
console.log(message) // 'Hello'
}
channel.emit('Hello')
Using a channel in a loop will loop over each emitted event until the channel is closed or the loop broken.
const channel = new Channel<string>()
async ()[copy channel] => {
for (const message of channel) {
console.log(message) // 'Hello', 'World'
}
}
channel.emit('Hello')
channel.emit('World')
Mutex
To manage variables that need to be written to from multiple threads we use a Mutex which holds a state and allows us to lock/unlock access to it, ensuring no one can get the value when it's being used.
const counterRef = new Mutex(0)
async ()[copy counterRef] => {
let counter = counterRef.unlock()
counter.increment()
// <-- counterRef.lock() automatically invoked
}
The Rust equivalent would look like:
fn main() {
let counterRef = Arc::new(Mutex::new(0));
let counterRef1 = counterRef.clone();
set_timeout(&(move || {
let mut counter = counterRef1.lock().unwrap();
counter* = counter* + 1;
}), 1000);
}
Error handling
At this stage, errors will be return values from tuples. Will probably support try
/catch
in the future.
const [ value, error ] = Number.fromString("Not a number")
if (error != null) {
// handle
}
Consuming External Libraries (FFI)
Would be done similarly to Deno's FFI implementation
Application Examples
Hello World
import console from '@std/console'
function main() {
const text: string = 'Hello World'
console.log(text)
}
We create an immutable reference to a string
object. We then give read-only access to the console.log
method, where the value is consumed.
Notes
- An application begins execution at the
main
function. - When
main
exits, it returns a status code0
as default - Imports starting with
@std/*
target the standard library
Simple HTTP Server
HTTP server that is multi-threaded and the handler functions are concurrent and possibly multi-threaded.
The API for the http library has not been finalized, this is an approximation
import { Server, HeaderType, ContentType } from '@std/http'
async function main() {
const server = new Server()
server.handle((req, res) => {
res.setHeader(HeaderType.ContentType, ContentType.Text)
res.send('Hello World')
})
server.listen(3000)
}
HTTP Server with State
HTTP server that increments a counter stored in a mutex. On each request the counter value will be incremented and the value sent back in the response. The HTTP server is multi-threaded and the handler function is scheduled on one of the available threads.
This is dependant on the design decision describing how ownership of values is passed into nested closures and not final
import { Server, HeaderType, ContentType } from '@std/http'
import { Mutex } from '@std/sync'
import { sleep, Duration } from '@std/time'
async function main() {
const server = new Server()
const counterRef = new Mutex(0)
// Increment counter every second
async ()[copy counterRef] => {
while (true) {
let value = counterRef.lock()
value.increment()
sleep(Duration.Second)
}
}
// Send the current value of the counter on the next request
server.handle((req, res)[copy counterRef] => {
const value = counterRef.lock()
res.setHeader(HeaderType.ContentType, ContentType.Text)
res.send(value)
})
server.listen(3000)
}