Skip to content

zaidharoon001/junu-spaghetti

Repository files navigation

Junu Spaghetti

Junu spaghetti is a language aimed at typing spaghetti code, especially kind people write in JS, Python, and other dynamically typed programming languages because you often don't want to write robust highly scalable code but a one-off script but would still like a nice type system to avoid debugging those pesky runtime type errors. It attempts this by using union types, polymorphism, and structural typing. What follows is a workable overview of Junu Spaghetti.

Base types, and variables

The language has all the expected base types of a high-level programming language, such as

let b = true
let n = 1
let s = "Hello"

You may have noticed the lack of type annotations, this is because Junu generally has local inference and therefore can infer the type of most local declarations. These are typed exactly the way you would expect them to be typed. Here are the results of typing

b: Bool
n: Num
s: String

Functions

Functions in Junu are first-class(and closures are also supported) meaning they can be passed to other functions, assigned to variables returned from functions. Following is an example of a function in Junu.

addNums(a: Num, b: Num) => Num {
    return a+b
}

and it's typed as

addNums: (Num, Num) -> Num

Now, Junu can infer the return type of functions that don't recurse, either by themselves or through mutual recursion, which this function does not so let's omit the return type and see if it works.

let addNums = (a: Num, b: Num) => {
    return a+b
}

As you might have noticed, we are using anonymous class functions here which is a common way of asking for type inference on return types in Junu. You may also consider omitting the curly braces if your function consists only of a single expression like the function discussed above.

let addNums = (a: Num, b: Num) => a+b

You may also want to forward declare functions if you plan to call them, in other functions before defining them. This is usually only used when mutual recursion comes into play, but our example here is not that.

f(Num) => Num
let g = (i: Num) => f(i)
f = (i: Num) => i+1

This will be typed as

f: (Num) -> Num
g: (Num) -> Num

Structural Types

These types are compared exclusively by their structures since they have no name. Following is an example of such a type

let obj = {a: 1, b: 3, c: "Hello"}

This will be typed as

obj: {a: Num, b: Num, c: String}

and any object even if aliased can be assigned to this variable

Type Unions

Type Unions are a form of subtyping which prefers composition over inheritance and is the heart and soul of this programming language. Unions here can be explicitly annotated, like this

let x: Num | String = 1
x = "Hello"

or inferred like such

let x = 1
x = "Hello"

both of which will be typed as

x: (Num | String)

You must note that a union can only be passed to a function that is either polymorphic over them or expects the exact the unions that are sent to it, this is done to preserve the soundness of the type system.

Narrowing Unions

Given a type, Num | String, there are ways to operate on it that can give you a singular type, and the main approach of doing that is using if statements, like such

let f = (a: Num | String) => {
    if a is Num {
        println(a+2)
    } else {
        println("Hello " + a)
    }
    return {}
}

This, when invoked with like this f("World"), prints Hello World but when invoked like this f(3) prints 5. As you might have noticed other than narrowing the type of a to Num in the first block, it narrowed down the type of the else block to String as well. This functions just as well with early returns too, for instance, the function above can be re-written as

let f = (a: Num | String) => {
    if a is Num {
        println(a+2)
        return {}
    }
    println("Hello " + a)
    return {}
}

and be typed just as well. There also exist other operators such as the infix operator notis which is the opposite of is, and the unary not operator which negates an expression.

Named Types

Named types allow for generic recursive structures, they are much like a pattern in Haskell but they have a type. Here's the type they have. Here's an example of a named type, with the name Pair

newtype Pair(a{}, b{}) = (a, b)

which can be instantiated as

let pair = Pair(1, "Hello")

which will be typed as

pair: Pair(Num, String)

and they can obviously be passed as generics and thus be nested

let pair = Pair(1, Pair({}, "Hello"))

while being typed as

pair: Pair(Num, Pair({}, String))

For a recursive type, when you want to generalize over its length, you can simply specify all the type variables in order like len function below does.

len(xs: Nil | Ls(n{})) => Num {
    if xs is Nil { return 0 }
    return len(xs.res)+1
}

let xs = Ls("d", Ls("ddjn", Ls("ddjn", {})))
println(len(xs))

Here xs would be typed as

xs: Ls(String, Ls(String, ({} | Ls(String))))

but will be generalized to Ls(String) when sending to a function, this generalization persists if it's returned from a function that generalizes it. When unions are put inside named types like

newtype Tup(a{}, b{}) = (a, b)
let t = Tup(1, "Hello")
t = Tup(1, 1)

their inner variables are turned into unions if that's a possiblility

Tup = Tup(a{}, b{}) = fromList [(a,a{}),(b,b{})]
t: Tup(Num, (Num | String))

Type Aliases

Type aliasing is used to build recursive and mutually recursive structures, here's an example

type Person = {name: String, age: Num, male: Bool, pet: Pet, parent: Nil | Person}

And objects of this kind can be interpreted as Person type.

let st = {name: "John", age: 10, male: true, pet: {species: "Cat", name: "Tom", color: "White"}, parent: {}}

Although, its immediate inferred type will be

st: {age: Num, male: Bool, name: String, parent: {}, pet: {color: String, name: String, species: String}}

but it can be sent to functions that accept Person type, like such

let st = {name: "John", age: 10, male: true, pet: {species: "Cat", name: "Tom", color: "White"}, parent: {}}
let mutatePerson = (s: Person, name: String) => {
    s.name = name
    return {}
}
mutatePerson(s, "Josh")

which is accepted to be sound. Even if we alter the type like this

st.parent = duplicate(st)

before sending it, as long as it's coercible to Person, it remains acceptable.

Parametric polymorphism and constraints

Here we will show how generics and parametric polymorphism works in this language. Following is an example of an identity function that can take any type and return that type.

let identity = (a: x{}) => a

Here, this is typed simply as

identity: (x{}) -> x{}

where x{} can literally be any type, and a value of the same type will be returned because it's inferred as (x{}) -> x{}. The empty curly braces denote that there are no constraints on what this type can be. Here are a few examples of its invocations.

let n = identity(1)
let s = identity("Hello")

Since functions are first-class, they can be passed to identity as well, like this

let f = identity((p: Num) => p+1)

All of which will be typed as

f: (Num) -> Num
n: Num
s: String

Polymorphic functions can also constraint the values they are be willing to take, for example, we can define a function that can take x, if and only if x has a field called name of type String. What follows is an example,

let getName = (obj: x{.name: String}) => obj.name

Here are examples of what its invocations can be

let resA = getName({name: "Ameer"})
let resB = getName({name: "John", age: 20})

It is important to note that this is not structural subtyping, since this function

let idName = (obj: x{.name: String}) => obj

when invoked like such let resB = idName({name: "John", age: 20}) would be typed as {age: Num, name: String}. Note: Row polymorphism along is planned although it's not yet implemented.

Arrays

Arrays in Junu are encoded entirely in its type system with the only aid coming from the language at runtime. This demonstrates the power of the system's union types quite effectively. You may initialize an array by either using the Array constructor or by using insert on an empty object. Here's an example of how type-safe heterogeneous arrays can be used in this language.

let insert = (arr: Nil | Array(m{}), a: n{}) => concat(arr, Array(a))
let res = insert(insert(insert({}, 1), true), "mooo")
res = concat(res, Array({p: true}))

An empty array can is to be represented as type Nil. This array can be indexed with the index function and it should return a type that's the union of all types present in the array. The union can then be narrowed with if statements. Following is an example of how this can be used

if res notis Nil {
    let x = index(res, 3)
    if x is String {
        println(x + "hoo")
    } else if x is Num {
        println(x+1)
    } else {
        println(x)
    }
}

Open functions

Open functions are the ad-hoc polymorphism of Junu. Open functions have many instantiations but those instantiations have to conform to the interface set by open functions, and can only be invoked with types that they are already instantiated with. Here's an example

open setName(t{}, String, a{.pet: b{.name: t{}}}) => b{.name: t{}} for a{.pet: b{.name: t{}}}

impl setName(s: String, area: String, person: Person) => {
    person.name = s
    return person.pet
} for Person

impl setName(i: Num, area: String, intkeeper: {pet: {name: Num}}) => {
    intkeeper.pet.name = i
    return intkeeper.pet
} for {pet: {name: Num}}

Now, this open function can only be called with either {pet: {name: Num}} or Person. Now let's call them with available

setName("Junu", "Collatz", {pet: {name: 1}})

It's also important to know that +, -, and other operators are actually overloadable because they are implemented as open functions but since user-defined operators are not yet part of the language, it only makes sense that these are to be implemented by using names, and that's why logical names are assigned to these. For instance add, sub, neq, and eq are names for +, -, !=, and == respectively. Here's an example of how you can implement add for your own type that represents a 2d point.

type Point = {x: Num, y: Num}

impl add(a: Point, b: Point) => {
    return {x: a.x + b.x, y: a.y + b.y}
} for Point

println({x: 1, y: 3} + {x: 2, y: 7})

and this, as you'd expect would output

{x: 3, y: 10, }