A high level interpreted language based on expressions. Built from scratch in Typescript.
Named after the the song by Sabrina Carpenter. And because — expressions. One of those is more important than the other. And its not the latter :-)
Some of its features are
- Dynamic typing
- Interpreted
- First class functions
- Garbage Collected
- Expressions everywhere
- Blocks!
The language is based off of the Monkey language from the book Writing an Interpreter in Go. I highly recommend it if you're interested in building your own language. I deviated from the book after the first couple of chapters so the final result looks quite different, but the core ideas are the same.
- Installation
- Variables
- Arrays
- Dictionaries
- Strings
- Functions
- Blocks
- Conditionals
- Loops
- Scoping
- Returning values
- Object Oriented Programming
- Comments
- Built-in functions
- Examples
You can install the interpreter from NPM
> npm install -g espressolang
You can execute espresso files by passing the file path as an argument. The examples folder contains a few example programs.
> espressolang examples/OOP.es
Or you can get to the REPL by running espresslang
without any arguments.
> espressolang
> let word = "hello";
undefined
> word + " world"
hello world
Variables are declared with the let
keyword.
let a = 10;
let b = 20;
a + b; // 30
There are currently 8 types of values: number
, boolean
, string
, array
, null
, undefined
, dictionaries
, and function
.
Note: Arrays, Dictionaries and Functions are stored as references, while the rest are primitive values.
print(10==10) // true
print([1]==[1]) // false
You can mutate already declared variables.
let a = 10;
a = 20;
Using let on an already declared variable works, but do keep in mind if the variable was in the global scope, it will be shadowed.
let a = 10;
{
let a = 20;
print(a); // 20
}
print(a); // 10
This is in contrast to simply using the =
operator, which will mutate the variable in the scope it was declared in.
let a = 10;
{
a = 20;
print(a); // 20
}
print(a); // 20
Arrays are declared with square brackets []
.
let a = [1, 2, 3];
push(a, 4);
a; // [1, 2, 3, 4]
You can access and mutate elements in an array using square brackets.
let a = [1, 2, 3];
a[0]; // 1
a[0] = 10;
a; // [10, 2, 3]
There is no restriction on the type of elements in an array.
let a = [1, "hello", true, [1, 2, 3], fn(x){ return x*x; }];
print(a[4](10)); // 100
print(a[1] + " world"); // hello world
Similar to other languages, arrays are references. This means that if you assign an array to another variable, both variables will point to the same array.
let a = [1, 2, 3];
let b = a;
b[0] = 10;
a; // [10, 2, 3]
Strings are declared with double quotes ""
.
let a = "hello";
let b = "world";
a + " " + b; // hello world
You can access characters in a string using square brackets.
let a = "hello";
print("I like to " + a[1] + "at");// I like to eat
However, strings are immutable. This means that you can't change a character in a string.
let a = "hello";
a[0] = "H"; // This will throw an error
Similar to other programming languages, Espresso supports key value pairs via dictionaries.
let a = {"name": "Shashwat", "age": 22};
a["name"]; // Shashwat
a["age"] = 18;
a["age"]; // 18
Similar to arrays, the values in a dictionary can be of any type.
let a = {"name": "Shashwat", "age": 22, "func": fn(x){ return x*x; }};
a["func"](10); // 100
The keys in a dictionary can be of any type. This includes objects, arrays and functions and even other dictionaries. However, for non-value types like these, the key is a pointer to the object, not the object itself.
This means that if you use an object as a key, you need to use the same object to access the value. Having two objects with the same properties will not work.
This is done to ensure the constant time complexity of dictionary operations while allowing for flexibility in the keys.
let dummy = {"name": "Shashwat"};
let a = {dummy: "hello"};
print(a[dummy]); // hello
print(a[{"name": "Shashwat"}]) // null
To create an empty dictionary, you can use the dict
function. {}
will be treated as an empty block(which evaluates to undefined
).
let a = dict();
let b = {};
b // undefined
Functions can be declared with the fn
keyword. Like everything else in Espresso, functions are expressions.
This means they can be assigned to variables.
let add = fn(a, b) {
return a + b;
};
add(10, 20); // 30
Passed to other functions.
let apply = fn(f, a, b) {
return f(a, b);
};
let add = fn(a, b) {
return a + b;
};
apply(add, 10, 20); // 30
And even returned from other functions.
let makeAdder = fn(a) {
return fn(b) {
return a + b;
};
};
let add10 = makeAdder(10);
add10(20); // 30
They also close over their environment.
let makeCounter = fn() {
let count = 0;
return fn() {
count = count + 1;
return count;
};
};
let counter = makeCounter();
counter(); // 1
counter(); // 2
Since functions are expressions, you can also have anonymous lambda functions.
let apply = fn(f, a, b) {
return f(a, b);
};
apply(fn(a, b) { a + b; }, 10, 20); // 30
Blocks are a sequence of statements enclosed in curly braces {}
. They are used to group statements together.
And yes, blocks are expressions too.
let a = {
let b = 10;
let c = 20;
b + c
};
a // 30
Variables declared inside a block are only available inside that block.
let a = {
let b = 10;
b
};
b // throws an error
You can use the if
keyword to create conditionals.
let name = "Shashwat";
if (name == "Shashwat") {
"Hello Shashwat!"
} else {
"Hello stranger!"
}
if
blocks are expressions too. They can have an optional else
block
let age = 22;
let type = if (age > 18) {
"adult"
} else {
"minor"
};
type // adult
Espresso supports the traditional while
loop.
let i = 0;
while (i < 10) {
print(i);
i = i + 1;
}
Unlike other languages however, while
loops in Espresso are expressions.
let i = 1;
let ans = while (i <=10) {
i = i + 1;
if (i == 5) {
return "done"; // This will break the loop and return "done" to the variable ans
}
i
};
ans // done
Espresso has block scoping. This means that variables declared inside a block are only available inside that block.
{
let a = 20;
print(a); // 20
}
print(a); // throws an error
Same is true for functions, loops and conditionals.
let age = 22;
if(age > 18){
let adult = true;
}
print(adult); // throws an error
You can use the return
keyword anywhere in the program. This will stop the execution of the current block and return the value.
let a = 10;
let b = 20;
return a + b; // 30
a / b; // This will not be executed
Of course, you can also return from functions.
let fib = fn(n){
if (n < 2) {
return n;
}
return fib(n - 1) + fib(n - 2);
}
Note that you can make do without the return
keyword in some cases.
If you don't use it, the last expression in the block will be returned. This is useful for short functions.
let max = fn(a, b) {
if (a > b) {
a
} else {
b
}
};
While all that sounds simple, there are a few things to keep in mind.
One, returning from a block moves control over to the nearest block up the chain that can catch the returning value. This means that the following code doesn't do what you might expect.
let func = fn(){
let count = 1;
let lastVal = while(count <= 10){
count = count + 1;
if(count == 5){
return count; // This will break the loop and return 5 to the variable lastVal
}
count
};
return "sad";
};
What do you think the value of func()
will be?
Unlike other languages where this would evaluate to 5
, in Espresso this will evaluate to "sad"
. This is because the return
statement breaks out of the loop and returns to the first block that catches its return value.
Unlike other languages, the return
statement is not specific to a function. It can be used anywhere in the program to return from the current block.
You might be wondering why I chose to implement it this way. The reason is that since all blocks are expressions, it would be inconsistent to have the return
statement behave differently.
The return
statement is meant to return a value from the current block, and that's what it does.
This also means there's no need for a break
statement. You can use return
to break out of a loop.
While this might look like a limitation at first, it's actually not that big of a deal. This is because return
statements do bubble up, waiting to be caught by the first block that can handle them.
For example, the above code can be made to behave as expected by removing the let
statement which was catching the return value. This way, the return value will bubble up to the function block.
let func = fn(){
let count = 1;
while(count < 10){
count = count + 1;
if(count == 5){
return count; // This will break the loop and end the function, returning 5
}
};
return "sad";
};
Now the value of func()
will be 5
, as expected.
Espresso doesn't support object oriented programming. However, you can pretty much get all the way there by using dictionaries and functions.
let Person = fn(name, age) {
let person = {
"name": name,
"age": age,
"greet": fn() {
print("Hello, my name is " + person["name"] + " and I am " + person["age"] + " years old.");
},
"birthday": fn() {
person["age"] = person["age"] + 1;
}
};
return person;
};
let john = Person("John", 30);
let jane = Person("Jane", 25);
john["greet"](); // Output: Hello, my name is John and I am 30 years old.
jane["greet"](); // Output: Hello, my name is Jane and I am 25 years old.
john["birthday"]();
john["greet"](); // Output: Hello, my name is John and I am 31 years old.
Behold, the power of first class functions, closures and dictionaries. Its functions all the way down.
Comments start with //
and go until the end of the line.
// This is a comment
let a = 10; // This is also a comment
Espresso comes with a few built-in functions.
-
len
: Returns the length of any iterable. -
print
: Prints a value to the console. -
push
: Pushes a value to the end of an array. -
pop
: Pops a value from the end of an array by default. Takes an optional index to pop from that instead. -
dict
: Creates an empty dictionary. -
str
: Converts a value to a string.
let count = len("hello"); // 5
print("hello", count, "world"); // hello 5 world
These functions are also first class values, so you can pass them around like any other function.
let myPrint = print;
myPrint("hello"); // hello
It's also important to note that these aren't reserved keywords. You can override them if you want.
{
// this causes you to lose the len function in this block
let len = fn(a) {
return 10;
};
print(len("hello")); // 10
}
print(len("hello")); // 5
That's a lot of information to take in. Here are a few examples to help you get started.
Let's implement the solution to the classic Two Sum problem.
let twoSum = fn(nums, target) {
let map = dict();
let len = len(nums);
let i = 0;
while (i < len) {
let num = nums[i];
let diff = target - num;
if (map[diff] != null) {
return [map[diff], i];
}
map[num] = i;
i = i + 1;
}
return null;
};
let nums = [2, 7, 11, 15];
let target = 9;
twoSum(nums, target); // Output: [0, 1]
We can also implement a simple recursive function to calculate the factorial of a number.
let factorial = fn(n) {
if (n == 0) {
return 1;
}
return n * factorial(n - 1);
};
factorial(5); // Output: 120
How about a reducer function?
let reduce = fn(arr, f, init) {
let result = init;
let len = len(arr);
let i = 0;
while (i < len) {
result = f(result, arr[i]);
i = i + 1;
}
return result;
};
let sum = reduce([1, 2, 3, 4], fn(acc, val) { acc + val; }, 0);
sum; // Output: 10
As you can see, despite the language being quite simple, you can still do quite a lot with it.