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.
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 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
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 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.
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 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 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.
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 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 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, }