Introduction
prepoly is a programming language that requires only minimal type annotations and compiles programs just in time.
The name comes from pre-typed and polymorphic. prepoly is an interpreted language that JIT-compiles your programs as it runs them, yet it checks types just before each function is executed. You don’t have to wait for compilation, but you still get the full benefit of type checking.
prepoly’s type system is built on Hindley-Milner type inference, which reduces the burden of writing type annotations. You can still add annotations explicitly to constrain the types of variables when you want to.
Features are summarized as follows:
- Just-in-time compilation
- Per-function type checking, performed just before each function runs
- Type inference for most types, resolved at execution time
- Structural subtyping with interface definitions
Playground
You can try prepoly on your browser:
Program
stdout
stderr
Chapter 1: Hello, world!
Let’s write your first prepoly program!
Write the following program into a hello.pp file.
println("Hello, world!")
Then, execute the program:
prepoly hello.pp
Output is as follows:
Hello, world!
You can define a main function as follows:
fun main() {
println("Hello, world!")
}
The execution result is the same as the previous one.
GCD: Greatest Common Divisor
Next, let’s write a practical example.
We can write a gcd function, which calculates the greatest common divisor, as follows:
fun gcd(a, b) {
if b == 0 {
return a
} else {
return gcd(b, a % b)
}
}
println(gcd(48, 36))
This outputs 12, which is correct!
Using an array
The following program calculates the gcd of all elements in an array:
const elems = [16, 36, 72, 192]
let result = elems[0]
for elem in elems.slice(1, elems.len()) {
result = gcd(result, elem)
}
println("GCD is {result}")
This program outputs GCD is 4.
Chapter 2: Defining types
We can define new types with their fields and methods as follows:
type Person = {
first_name: string,
last_name: string,
display(self) {
return "{self.first_name} {self.last_name}"
},
}
fun main() {
const newton = Person {
first_name: "Isac",
last_name: "Newton",
}
println("{newton.display()}")
}
This program outputs Isac Newton.
We can define “OR” types:
type DegreeProgram =
| Bachelor {
year: int32,
}
| Master {
year: int32,
}
| Doctor {
year: int32,
}
Using DegreeProgram type, we can define Student type:
type Student: Person = {
first_name,
last_name,
display(self) {
return "{self.id}: {self.first_name} {self.last_name}"
},
id,
program: DegreeProgram,
}
Here, we wrote the Person type on the left of Student.
This requires that the Student type include all fields of the Person type.
Using these definitions, let’s write a complete program.
Here we enhance display with a match expression that formats each DegreeProgram variant:
type Person = {
first_name: string,
last_name: string,
display(self) {
return "{self.first_name} {self.last_name}"
},
}
type DegreeProgram =
| Bachelor {
year: int32,
}
| Master {
year: int32,
}
| Doctor {
year: int32,
}
type Student: Person = {
first_name,
last_name,
display(self) {
const program = match self.program {
Bachelor { year } => "Bachelor {year}",
Master { year } => "Master {year}",
Doctor { year } => "Doctor {year}",
}
return "{self.id} ({program}): {self.first_name} {self.last_name}"
},
id,
program: DegreeProgram,
}
fun main() {
const newton = Student {
first_name: "Isac",
last_name: "Newton",
id: 1001,
program: DegreeProgram.Master { year: 1 },
}
println("{newton.display()}")
println("{newton}")
}
Executing this shows the following output:
1001 (Master 1): Isac Newton
Student {
first_name: Isac,
last_name: Newton,
id: 1001,
program: DegreeProgram.Master {
year: 1,
},
}
In the above example, we didn’t write any type annotation for Student.id.
So we can write a string as the value of Student.id:
const edison = Student {
first_name: "Thomas",
last_name: "Edison",
id: "AL17001",
program: DegreeProgram.Doctor { year: 3 },
}
println("{edison.display()}")
This program can be placed alongside the above newton example, and the output is as follows:
AL17001 (Doctor 3): Thomas Edison
Chapter 3: Modules
prepoly organizes code into modules: every file is a module, and directories form the module path. Let’s split a program across several files.
First, write students/types.pp:
type _Person = {
first_name: string,
last_name: string,
display(self) {
return "{self.first_name} {self.last_name}"
},
}
type DegreeProgram =
| Bachelor {
year: int32,
}
| Master {
year: int32,
}
| Doctor {
year: int32,
}
type Student: _Person = {
first_name,
last_name,
display(self) {
return "{self.id}: {self.first_name} {self.last_name}"
},
id,
program: DegreeProgram,
}
Then, write students.pp:
import students.types.{ DegreeProgram, Student }
fun get_students() {
return [
Student {
first_name: "Isac",
last_name: "Newton",
id: 1001,
program: DegreeProgram.Master { year: 1 },
},
Student {
first_name: "Thomas",
last_name: "Edison",
id: 1002,
program: DegreeProgram.Doctor { year: 3 },
},
]
}
Now we can use these modules in show_students.pp:
import students.{ get_students }
println(get_students())
Then, execute it:
prepoly show_students.pp
This prints the list of students.
Note that anything you define with a name starting with _ becomes private to its module.
So you can’t use _Person outside students/types.pp.
Chapter 4: null and Result
prepoly has a null type and a Result type.
Let’s see an example:
fun double(a: int32?) {
if a {
return a * 2
} else {
return error("null")
}
}
println(double(2))
println(double(null))
The variable a of the function double has the type int32?.
The ? means that the value may be null.
A value that may be null must be checked with an if expression.
Calling the error function makes the return value a Result.Err.
When a function returns a plain value where a Result is expected, it is wrapped as Result.Ok.
So the output of the above program is as follows:
Result.Ok {
value: 4,
}
Result.Err {
error: null,
}
We can omit the type annotation for nullable types.
But if a function receives null without a null check, the type check fails and the function is not executed.