Hern, a small language experiment
For the last few weeks I’ve been working on a small programming language called Hern.
I don’t really want to turn it into a real language, I’m not trying to create the next Rust, or the next Haskell, or anything like that. The point is much smaller and more personal: I wanted a playground where I could try some type system ideas and see how they feel when they are all forced to live together in one language.
It’s one thing to read about row polymorphism, traits, higher-kinded types, type inference, or algebraic data types in isolation. It’s another thing to put them next to each other and ask: does this still feel like one coherent language?
That is the interesting part to me.
Hern is less a new invention than a small collage of old ideas I like: ML-style
algebraic data types and inference, Haskell-style type classes, fixity, and
do notation, Rust-ish traits and tooling pragmatism, and row-polymorphic
records for structural data.
The basic shape
At the surface, Hern is roughly in the ML/Rust family. It has sum types, pattern matching, records, functions, traits, and inference.
A tiny Peano natural number type looks like this:
typetype
Declares a nominal type or a type alias.
type introduces a named sum type with variants. type alias gives a name to
an existing type expression without creating a fresh nominal identity.
pub type Option('a) = Some('a) | None
type alias Name = string
Nattype Nat = Z | S(Nat)
= ZZ
| SS(Nat)
(Nattype Nat = Z | S(Nat)
)
fnfn() -> Nat
Declares a function, method, or function literal.
Top-level fn declarations create callable values. Inside traits and impls,
fn declares required or provided methods. In expressions, fn creates an
anonymous function.
fn add(lhs, rhs) {
lhs + rhs
}
let inc = fn(value) { value + 1 };
zerofn() -> Nat
() -> Nattype Nat = Z | S(Nat)
{
ZNat
}
fnfn(Nat) -> Nat
Declares a function, method, or function literal.
Top-level fn declarations create callable values. Inside traits and impls,
fn declares required or provided methods. In expressions, fn creates an
anonymous function.
fn add(lhs, rhs) {
lhs + rhs
}
let inc = fn(value) { value + 1 };
succfn(Nat) -> Nat
(nNat
: Nattype Nat = Z | S(Nat)
) -> Nattype Nat = Z | S(Nat)
{
Sfn(Nat) -> Nat
(nNat
)
}
fnfn(Nat, Nat) -> Nat
Declares a function, method, or function literal.
Top-level fn declarations create callable values. Inside traits and impls,
fn declares required or provided methods. In expressions, fn creates an
anonymous function.
fn add(lhs, rhs) {
lhs + rhs
}
let inc = fn(value) { value + 1 };
addfn(Nat, Nat) -> Nat
(lhsNat
: Nattype Nat = Z | S(Nat)
, rhsNat
: Nattype Nat = Z | S(Nat)
) -> Nattype Nat = Z | S(Nat)
{
matchmatch: Nat
Selects a branch by pattern matching on a value.
match compares a scrutinee against patterns and evaluates the first matching
arm. All arms must produce compatible result types.
match maybe {
Some(value) -> value,
None -> 0,
}
lhsNat
{
ZNat
->Nat
rhsNat
,
SNat
(restNat
) ->Nat
succfn(Nat) -> Nat
(addfn(Nat, Nat) -> Nat
(restNat
, rhsNat
)),
}
}
This is not exciting by itself, most functional languages can express this just fine, but it is a good starting point because it immediately exercises a few important things: recursive types, recursive functions, constructors, pattern matching, and inference inside branches.
The goal is that the language should feel predictable here. Nat is a type, Z
and S are constructors, add is just a function, and recursive calls work
without any special ceremony.
Records without ceremony
One of the ideas I wanted to try from the beginning was row polymorphism. In practice, that means a function can ask for a record with a particular field without caring about the other fields.
fnfn(#{ x: 'a, ..'b }) -> 'a
Declares a function, method, or function literal.
Top-level fn declarations create callable values. Inside traits and impls,
fn declares required or provided methods. In expressions, fn creates an
anonymous function.
fn add(lhs, rhs) {
lhs + rhs
}
let inc = fn(value) { value + 1 };
get_xfn(#{ x: 'a, ..'b }) -> 'a
(obj#{ x: 'a, ..'b }
) {
obj#{ x: 'a, ..'b }
.x'a
}
letlet
Introduces a value binding.
let binds a pattern to a value. Add a type annotation when you want to make
the expected type explicit.
let answer = 42;
let point: #{ x: int, y: int } = #{ x: 1, y: 2 };
firststring
= get_xfn(#{ x: string, y: int }) -> string
(#{ x#{ x: string, y: int }
: "hello"string
, y#{ x: string, y: int }
: 1int
});
letlet
Introduces a value binding.
let binds a pattern to a value. Add a type annotation when you want to make
the expected type explicit.
let answer = 42;
let point: #{ x: int, y: int } = #{ x: 1, y: 2 };
secondint
= get_xfn(#{ x: int, z: bool }) -> int
(#{ x#{ x: int, z: bool }
: 42int
, z#{ x: int, z: bool }
: truebool
});
The function get_x does not need an interface, a class, or a named record
type. The type checker can infer that it accepts any record with an x field.
This is also where language design gets delicate, because records should compose well with inference, but they should not make every error message impossible to read. A lot of Hern has been about poking at that boundary.
Traits as dictionaries
Hern also has traits. Internally, I think of them in the usual dictionary passing way: a trait implementation is evidence that some operation exists for some type. This is the classic type class story in a different coat: constraints in the source language become dictionaries the compiler knows how to pass around.
For example, equality for Nat can be written manually:
implimpl
Provides methods or trait behavior for a type.
An inherent impl adds methods directly to a type. A trait impl supplies the
methods required by a trait for a specific target type.
impl Box {
fn value(self) { self.value }
}
impl ToString for Box {
fn to_string(box) { box.value.to_string() }
}
Eq forfor
Iterates over values from an iterable expression.
for evaluates a body once for each element produced by an Iterable
implementation. The binding may be a pattern.
let mut total = 0;
for value in values {
total = total + value;
}
Nattype Nat = Z | S(Nat)
{
fnfn(Nat, Nat) -> bool
Declares a function, method, or function literal.
Top-level fn declarations create callable values. Inside traits and impls,
fn declares required or provided methods. In expressions, fn creates an
anonymous function.
fn add(lhs, rhs) {
lhs + rhs
}
let inc = fn(value) { value + 1 };
==fn(Nat, Nat) -> bool
(lhsNat
, rhsNat
) {
matchmatch: bool
Selects a branch by pattern matching on a value.
match compares a scrutinee against patterns and evaluates the first matching
arm. All arms must produce compatible result types.
match maybe {
Some(value) -> value,
None -> 0,
}
lhsNat
{
Zbool
->bool
matchmatch: bool
Selects a branch by pattern matching on a value.
match compares a scrutinee against patterns and evaluates the first matching
arm. All arms must produce compatible result types.
match maybe {
Some(value) -> value,
None -> 0,
}
rhsNat
{ Zbool
->bool
truebool
, _bool
->bool
falsebool
},
Sbool
(lNat
) ->bool
matchmatch: bool
Selects a branch by pattern matching on a value.
match compares a scrutinee against patterns and evaluates the first matching
arm. All arms must produce compatible result types.
match maybe {
Some(value) -> value,
None -> 0,
}
rhsNat
{ Sbool
(rNat
) ->bool
lNat
==fn('a, 'a) -> bool
where:
'a: Eq
infix 4
Return true when both values are equal. rNat
, _bool
->bool
falsebool
},
}
}
fnfn(Nat, Nat) -> bool
Declares a function, method, or function literal.
Top-level fn declarations create callable values. Inside traits and impls,
fn declares required or provided methods. In expressions, fn creates an
anonymous function.
fn add(lhs, rhs) {
lhs + rhs
}
let inc = fn(value) { value + 1 };
!=fn(Nat, Nat) -> bool
(lhsNat
, rhsNat
) { !(lhsNat
==fn('a, 'a) -> bool
where:
'a: Eq
infix 4
Return true when both values are equal. rhsNat
) }
}
letlet
Introduces a value binding.
let binds a pattern to a value. Add a type annotation when you want to make
the expected type explicit.
let answer = 42;
let point: #{ x: int, y: int } = #{ x: 1, y: 2 };
samebool
= Sfn(Nat) -> Nat
(ZNat
) ==fn('a, 'a) -> bool
where:
'a: Eq
infix 4
Return true when both values are equal. Sfn(Nat) -> Nat
(ZNat
);
letlet
Introduces a value binding.
let binds a pattern to a value. Add a type annotation when you want to make
the expected type explicit.
let answer = 42;
let point: #{ x: int, y: int } = #{ x: 1, y: 2 };
differentbool
= Sfn(Nat) -> Nat
(ZNat
) ==fn('a, 'a) -> bool
where:
'a: Eq
infix 4
Return true when both values are equal. ZNat
;
What I like about this example is that it is recursive in two ways. The data
type is recursive, and the trait implementation is recursive as well. The
recursive call to == on the payload should simply find the implementation
currently being defined. That sounds obvious from the user’s side, but it is
exactly the kind of thing that breaks if the internal model is not quite right.
This is why building a toy language is useful. You can very quickly discover which parts of your mental model are vague.
I wrote about Haskell type classes separately in Breaking apart the Haskell type class, and Hern is partly me trying to make that model feel concrete in a compiler I can hold in my head.
Higher-kinded experiments
The more experimental part is higher-kinded traits. Hern’s prelude has a small
Functor trait, which can be implemented for type constructors like Option,
arrays, or Result(_, string).
fnfn(int) -> string
Declares a function, method, or function literal.
Top-level fn declarations create callable values. Inside traits and impls,
fn declares required or provided methods. In expressions, fn creates an
anonymous function.
fn add(lhs, rhs) {
lhs + rhs
}
let inc = fn(value) { value + 1 };
show_intfn(int) -> string
(xint
: inttype int
Built-in integer number.
int is the default type for whole-number literals.) -> stringtype string
Built-in text value.
string stores UTF-8 text and is the result type of string literals. {
to_stringfn('a) -> string
where:
'a: ToString
(xint
)
}
letlet
Introduces a value binding.
let binds a pattern to a value. Add a type annotation when you want to make
the expected type explicit.
let answer = 42;
let point: #{ x: int, y: int } = #{ x: 1, y: 2 };
maybe_textOption(string)
= Functortrait Functor 'f
Types that can map over contained values.::mapfn(Option(int), fn(int) -> string) -> Option(string)
(Somefn(int) -> Option(int)
(41int
), show_intfn(int) -> string
);
letlet
Introduces a value binding.
let binds a pattern to a value. Add a type annotation when you want to make
the expected type explicit.
let answer = 42;
let point: #{ x: int, y: int } = #{ x: 1, y: 2 };
array_text[string] = Functortrait Functor 'f
Types that can map over contained values.::mapfn([int], fn(int) -> string) -> [string]
([1int
, 2int
, 3int
], show_intfn(int) -> string
);
letlet
Introduces a value binding.
let binds a pattern to a value. Add a type annotation when you want to make
the expected type explicit.
let answer = 42;
let point: #{ x: int, y: int } = #{ x: 1, y: 2 };
result_textResult(string, string) = Functortrait Functor 'f
Types that can map over contained values.(Resulttype Result('a, 'e) = Ok('a) | Err('e)
A success or error value.(__
Type hole inferred from context.
_ asks the compiler to infer this type argument from the surrounding type., stringtype string
Built-in text value.
string stores UTF-8 text and is the result type of string literals.))::mapfn(Result(int, string), fn(int) -> string) -> Result(string, string)
(Okfn(int) -> Result(int, string)
(5int
), show_intfn(int) -> string
);
Type constructors can be passed around at the type level, partially applied types can participate in trait resolution, and the surface language can still remain relatively small.
That is very much borrowing from the Haskell lineage: abstractions like
Functor become more interesting when they talk about type constructors rather
than concrete types, but the language still has to make the simple cases feel
simple.
That is the whole appeal of this project, I can just take ideas that usually live in separate language communities and force them to interact.
Small practical things
I also don’t want Hern to be purely an exercise in type theory. If the language is supposed to be pleasant to use, it needs a few boring constructs that make ordinary programs easier to write.
For example, it has let mut and reassignment. I still like immutable values by
default, but sometimes a small loop with an accumulator is the clearest thing to
write.
fnfn([int]) -> int
Declares a function, method, or function literal.
Top-level fn declarations create callable values. Inside traits and impls,
fn declares required or provided methods. In expressions, fn creates an
anonymous function.
fn add(lhs, rhs) {
lhs + rhs
}
let inc = fn(value) { value + 1 };
sum_itemsfn([int]) -> int
(xs[int]: [inttype int
Built-in integer number.
int is the default type for whole-number literals.]) -> inttype int
Built-in integer number.
int is the default type for whole-number literals. {
letlet
Introduces a value binding.
let binds a pattern to a value. Add a type annotation when you want to make
the expected type explicit.
let answer = 42;
let point: #{ x: int, y: int } = #{ x: 1, y: 2 };
mutmut
Marks a binding or parameter as mutable.
Use mut when a binding may be reassigned or when a parameter may be consumed
by operations that require a mutable place.
let mut count = 0;
count = count + 1;
totalint
= 0int
;
forfor: ()
Iterates over values from an iterable expression.
for evaluates a body once for each element produced by an Iterable
implementation. The binding may be a pattern.
let mut total = 0;
for value in values {
total = total + value;
}
xint
inin
Separates a for loop pattern from its iterable expression.
In for pattern in iterable, the pattern receives each element produced by the
iterable expression.
for (index, value) in values.indexed() {
print("#{index}: #{value}");
}
xs[int] {
totalint
= totalint
+fn('lhs, 'rhs) -> 'output
where:
'lhs 'rhs -> 'output: Add
infixl 6
Add or combine two values. xint
;
}
totalint
}
letlet
Introduces a value binding.
let binds a pattern to a value. Add a type annotation when you want to make
the expected type explicit.
let answer = 42;
let point: #{ x: int, y: int } = #{ x: 1, y: 2 };
totalint
= sum_itemsfn([int]) -> int
([1int
, 2int
, 3int
, 4int
]);
The for loop also works with patterns, which makes it useful with tuples and
records.
letlet
Introduces a value binding.
let binds a pattern to a value. Add a type annotation when you want to make
the expected type explicit.
let answer = 42;
let point: #{ x: int, y: int } = #{ x: 1, y: 2 };
pairs[(int, int)] = [(1int
, 2int
), (3int
, 4int
)];
letlet
Introduces a value binding.
let binds a pattern to a value. Add a type annotation when you want to make
the expected type explicit.
let answer = 42;
let point: #{ x: int, y: int } = #{ x: 1, y: 2 };
mutmut
Marks a binding or parameter as mutable.
Use mut when a binding may be reassigned or when a parameter may be consumed
by operations that require a mutable place.
let mut count = 0;
count = count + 1;
totalint
= 0int
;
forfor: ()
Iterates over values from an iterable expression.
for evaluates a body once for each element produced by an Iterable
implementation. The binding may be a pattern.
let mut total = 0;
for value in values {
total = total + value;
}
(xint
, yint
) inin
Separates a for loop pattern from its iterable expression.
In for pattern in iterable, the pattern receives each element produced by the
iterable expression.
for (index, value) in values.indexed() {
print("#{index}: #{value}");
}
pairs[(int, int)] {
totalint
= totalint
+fn('lhs, 'rhs) -> 'output
where:
'lhs 'rhs -> 'output: Add
infixl 6
Add or combine two values. xint
+fn('lhs, 'rhs) -> 'output
where:
'lhs 'rhs -> 'output: Add
infixl 6
Add or combine two values. yint
;
}
letlet
Introduces a value binding.
let binds a pattern to a value. Add a type annotation when you want to make
the expected type explicit.
let answer = 42;
let point: #{ x: int, y: int } = #{ x: 1, y: 2 };
rows[#{ a: int, b: int }]
= [#{ a#{ a: int, b: int }
: 5int
, b#{ a: int, b: int }
: 6int
}];
letlet
Introduces a value binding.
let binds a pattern to a value. Add a type annotation when you want to make
the expected type explicit.
let answer = 42;
let point: #{ x: int, y: int } = #{ x: 1, y: 2 };
mutmut
Marks a binding or parameter as mutable.
Use mut when a binding may be reassigned or when a parameter may be consumed
by operations that require a mutable place.
let mut count = 0;
count = count + 1;
field_sumint
= 0int
;
forfor: ()
Iterates over values from an iterable expression.
for evaluates a body once for each element produced by an Iterable
implementation. The binding may be a pattern.
let mut total = 0;
for value in values {
total = total + value;
}
#{ aint
, .. } inin
Separates a for loop pattern from its iterable expression.
In for pattern in iterable, the pattern receives each element produced by the
iterable expression.
for (index, value) in values.indexed() {
print("#{index}: #{value}");
}
rows[#{ a: int, b: int }]
{
field_sumint
= field_sumint
+fn('lhs, 'rhs) -> 'output
where:
'lhs 'rhs -> 'output: Add
infixl 6
Add or combine two values. aint
;
}
There is also do notation. This is one of those features that
looks a little magical until you remember that it is just syntax for chaining
monadic operations. I mainly added it because I wanted code that returns
Option, Result, or parser combinators to remain readable.
fnfn(int) -> Option(int)
Declares a function, method, or function literal.
Top-level fn declarations create callable values. Inside traits and impls,
fn declares required or provided methods. In expressions, fn creates an
anonymous function.
fn add(lhs, rhs) {
lhs + rhs
}
let inc = fn(value) { value + 1 };
half_if_evenfn(int) -> Option(int)
(nint
: inttype int
Built-in integer number.
int is the default type for whole-number literals.) -> Optiontype Option('a) = Some('a) | None
An optional value.(inttype int
Built-in integer number.
int is the default type for whole-number literals.) {
ifif: Option(int)
Branches on a boolean condition.
if is an expression. Both branches must agree on the result type when the
result is used.
let absolute = if value < 0 {
0 - value
} else {
value
};
nint
%fn('lhs, 'rhs) -> 'output
where:
'lhs 'rhs -> 'output: Mod
infixl 7
Return the remainder after division. 2int
==fn('a, 'a) -> bool
where:
'a: Eq
infix 4
Return true when both values are equal. 0int
{ Somefn(int) -> Option(int)
(nint
/fn('lhs, 'rhs) -> 'output
where:
'lhs 'rhs -> 'output: Div
infixl 7
Divide the left value by the right value. 2int
) } elseelse
Provides the fallback branch for an if expression.
Because if is an expression in Hern, the then and else branches must
produce compatible types.
let label = if score > 0 {
"positive"
} else {
"zero-or-negative"
};
{ NoneOption(int)
}
}
letlet
Introduces a value binding.
let binds a pattern to a value. Add a type annotation when you want to make
the expected type explicit.
let answer = 42;
let point: #{ x: int, y: int } = #{ x: 1, y: 2 };
resultOption(int)
= dodo
Starts a block expression used for scoped computation.
do introduces an expression block where statements can prepare a final value.
It is useful when a larger expression needs local bindings.
let total = do {
let base = price();
base + tax(base)
};
{
letlet
Introduces a value binding.
let binds a pattern to a value. Add a type annotation when you want to make
the expected type explicit.
let answer = 42;
let point: #{ x: int, y: int } = #{ x: 1, y: 2 };
aint
<-fn('f('a), fn('a) -> 'f('b)) -> 'f('b)
where:
'f: Monad
infixl 1
Sequence a computation that returns the same context. half_if_evenfn(int) -> Option(int)
(8int
);
letlet
Introduces a value binding.
let binds a pattern to a value. Add a type annotation when you want to make
the expected type explicit.
let answer = 42;
let point: #{ x: int, y: int } = #{ x: 1, y: 2 };
bint
<-fn('f('a), fn('a) -> 'f('b)) -> 'f('b)
where:
'f: Monad
infixl 1
Sequence a computation that returns the same context. half_if_evenfn(int) -> Option(int)
(aint
);
Somefn(int) -> Option(int)
(aint
+fn('lhs, 'rhs) -> 'output
where:
'lhs 'rhs -> 'output: Add
infixl 6
Add or combine two values. bint
)
};
A thing I came to realise is that language can have a very clever type system, but if the everyday code is unpleasant to write, the cleverness does not help very much.
Operators and dependencies
Hern lets functions and trait methods define arbitrary operators with explicit fixity and precedence. This is mostly inspired by Haskell, but I wanted it to fit together with the trait system rather than be a separate parser trick.
fnfn(int, int) -> int
infixl 9
Declares a function, method, or function literal.
Top-level fn declarations create callable values. Inside traits and impls,
fn declares required or provided methods. In expressions, fn creates an
anonymous function.
fn add(lhs, rhs) {
lhs + rhs
}
let inc = fn(value) { value + 1 };
infixlinfixl
Declares a left-associative operator fixity.
Use infixl after fn when same-precedence uses should group from the left.
fn infixl 6 +(lhs, rhs) {
add(lhs, rhs)
}
9 <+>fn(int, int) -> int
infixl 9
(aint
: inttype int
Built-in integer number.
int is the default type for whole-number literals., bint
: inttype int
Built-in integer number.
int is the default type for whole-number literals.) -> inttype int
Built-in integer number.
int is the default type for whole-number literals. {
aint
+fn('lhs, 'rhs) -> 'output
where:
'lhs 'rhs -> 'output: Add
infixl 6
Add or combine two values. bint
}
letlet
Introduces a value binding.
let binds a pattern to a value. Add a type annotation when you want to make
the expected type explicit.
let answer = 42;
let point: #{ x: int, y: int } = #{ x: 1, y: 2 };
resultint
= 1int
<+>fn(int, int) -> int
infixl 9
2int
<+>fn(int, int) -> int
infixl 9
3int
;
The prelude uses the same mechanism for normal arithmetic operators. Addition is not hard-coded as one special operation on one type. It is a multi-parameter trait with a functional dependency from the input types to the output type.
traittrait
Declares behavior that types can implement.
A trait names a set of methods and the type parameters they operate on.
Functions can require trait behavior with a where clause.
pub trait Show 'a {
fn show(value: 'a) -> string
}
fn print_show(value: 'a) where 'a: Show {
print(Show::show(value))
}
Add2trait Add2 'lhs 'rhs 'output
'lhs 'rhs -> 'output {
fnfn('lhs, 'rhs) -> 'output
Declares a function, method, or function literal.
Top-level fn declarations create callable values. Inside traits and impls,
fn declares required or provided methods. In expressions, fn creates an
anonymous function.
fn add(lhs, rhs) {
lhs + rhs
}
let inc = fn(value) { value + 1 };
infixlinfixl
Declares a left-associative operator fixity.
Use infixl after fn when same-precedence uses should group from the left.
fn infixl 6 +(lhs, rhs) {
add(lhs, rhs)
}
6 ++fn('lhs, 'rhs) -> 'output
(lhs: 'lhs'lhs
Type variable.
Type variables stand for types chosen by the caller or inferred by the compiler., rhs: 'rhs'rhs
Type variable.
Type variables stand for types chosen by the caller or inferred by the compiler.) -> 'output'output
Type variable.
Type variables stand for types chosen by the caller or inferred by the compiler.
}
fnfn('a, 'a) -> 'a
where:
'a 'a -> 'a: Add2
Declares a function, method, or function literal.
Top-level fn declarations create callable values. Inside traits and impls,
fn declares required or provided methods. In expressions, fn creates an
anonymous function.
fn add(lhs, rhs) {
lhs + rhs
}
let inc = fn(value) { value + 1 };
add_twicefn('a, 'a) -> 'a
where:
'a 'a -> 'a: Add2
(x'a
: 'a'a
Type variable.
Type variables stand for types chosen by the caller or inferred by the compiler., y'a
: 'a'a
Type variable.
Type variables stand for types chosen by the caller or inferred by the compiler.) -> 'a'a
Type variable.
Type variables stand for types chosen by the caller or inferred by the compiler.
wherewhere
Introduces type and trait constraints.
where states which trait implementations a generic function, method, or impl
requires. Constraints are checked by the type system and used for dispatch.
fn stringify(value: 'a) -> string where 'a: ToString {
value.to_string()
}
'a'a
Type variable.
Type variables stand for types chosen by the caller or inferred by the compiler. 'a'a
Type variable.
Type variables stand for types chosen by the caller or inferred by the compiler. -> 'a'a
Type variable.
Type variables stand for types chosen by the caller or inferred by the compiler.: Add2
{
x'a
++fn('lhs, 'rhs) -> 'output
where:
'lhs 'rhs -> 'output: Add2
infixl 6
y'a
++fn('lhs, 'rhs) -> 'output
where:
'lhs 'rhs -> 'output: Add2
infixl 6
y'a
}
The -> 'output part is the functional dependency. It says that once the left
and right argument types are known, the output type is determined. Without that
kind of rule, an expression like x + y can become ambiguous very quickly. The
Haskell world has a long-running parallel design conversation around
functional dependencies and type families; Hern is
only using the small piece it needs here.
The same shape is useful for indexing:
traittrait
Declares behavior that types can implement.
A trait names a set of methods and the type parameters they operate on.
Functions can require trait behavior with a where clause.
pub trait Show 'a {
fn show(value: 'a) -> string
}
fn print_show(value: 'a) where 'a: Show {
print(Show::show(value))
}
Index2trait Index2 'receiver 'key 'output
'receiver 'key -> 'output {
fnfn('receiver, 'key) -> 'output
Declares a function, method, or function literal.
Top-level fn declarations create callable values. Inside traits and impls,
fn declares required or provided methods. In expressions, fn creates an
anonymous function.
fn add(lhs, rhs) {
lhs + rhs
}
let inc = fn(value) { value + 1 };
getfn('receiver, 'key) -> 'output
(receiver: 'receiver'receiver
Type variable.
Type variables stand for types chosen by the caller or inferred by the compiler., key: 'key'key
Type variable.
Type variables stand for types chosen by the caller or inferred by the compiler.) -> 'output'output
Type variable.
Type variables stand for types chosen by the caller or inferred by the compiler.
}
An array indexed by an int produces an element, a map indexed by a key
produces a value, and a string indexed by a range produces a string. These are
all the same operation from the user’s point of view, but the type system still
has enough information to know the result type.
This is one of the places where Hern feels closest to what I wanted from the project. Operators, traits, and inference are not separate features bolted next to each other, they all describe the same underlying idea: some operation is available for some types, and resolving that operation should determine the rest of the expression.
The compiler
The compiler is written in Rust and currently targets LuaJIT, and that choice is mostly pragmatic. Lua is small, embeddable, fast enough for this experiment, and easy to generate, LuaJIT is a simple target that has lots of useful features.
The tooling around the language is already more complete than I expected:
- a compiler
- a standard prelude
- a test runner
- an LSP server
- a tree-sitter grammar
- editor integrations
- documentation hovers on this website
This website now analyzes Hern snippets while rendering Markdown, so the code blocks are highlighted with tree-sitter, but the hover types come from the Hern compiler itself. It means the compiler is not only a binary you run in a terminal, but also a library that can feed other tools.
One thing that feels especially nice is that most of the tooling still lives in a single small binary, including the embedded LuaJIT runtime. That keeps the project easy to move around: the compiler, test runner, language tooling, and runtime all travel together.