• Stars
    star
    1,443
  • Rank 32,613 (Top 0.7 %)
  • Language
    HTML
  • Created about 3 years ago
  • Updated about 1 year ago

Reviews

There are no reviews yet. Be the first to send feedback to the community and the maintainers!

Repository Details

TypeScript with a Borrow Checker. Multi-threaded, Tiny binaries. No GC. Easy to write.


 

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

Starting a concurrent task with async

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')
}

Channels and yield

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 code 0 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)
}