Lake

A language for lay programmers

June 2025. I'm actively (20 hours per week) working on a first public alpha version. The purpose of this first alpha version would be to showcase Lake and convince other developers to help build a first beta version.

Lake is designed for lay programmers: non-expert and non-professional programmers. Currently, without Lake, lay programmers must choose between very limited languages designed for children (e.g. Scratch) or powerful languages designed to be also used by experts and professionals (e.g. Python and JavaScript). These powerful languages force lay programmers to deal with confusing matters that are not relevant to their limited needs. With Lake I want to offer a language that is less powerful but powerful enough for a lay programmer, such that many confusing matters are completely removed.

Here are some examples of what a lay programmer could use Lake for:

Lake is not designed for learning how to program; it is an ergonomic tool for real work. Besides Lake I work on a project with the goal of teaching programming to a general audience (future lay programmers). Part of this project is a series of progressive languages, optimized for learning. The plan is to release Lake at the same time as this educational project, so an absolute beginner can smoothly progress from the educational languages into Lake.

Some more detail:

1: A runtime in the browser that is blocking and that you can pause/stop at any moment comes with the downside that it is orders of magnitude slower than such a runtime as a native client would be. However, in the spirit of Lake, it is still fast enough.

Claim

The following is not aimed at potential future users of Lake, but to have timestamped examples in the public record. The argumentation is cursory and assumes a familiarity with programming languages.

Let's say you want to model a player who has some scores. Later you want to increase the first score by one. In JavaScript this would typically be implemented as follows. I use let to be more apples-to-apples with Lake, which has only mutable bindings.

let player = {scores : [99, 50, 25]}
player.scores[0] += 1

Updating the score is convenient to write. But, the representation of the player is mutable. In Lake I certainly want immutable compound values. But, in languages that have immutable compound values, implementing the above typically involves more non-imperative bells and whistles than I'd like for Lake. I needed a way to somewhat ergonomically work imperatively with nested immutable values. After experimentation I settled on this:

bind player : @.[scores : [99, 50, 25]]
rebind player : $.scores[1]::$ + 1

(Lists in Lake are 1-indexed.)

This tiny example shows how two aspects of Lake's syntax and semantics that together enable something novel: ergonomic imperative use of nested immutable values. In addition, this example shows an aspect that isn't related to the novelty, but is examplary of Lake's design philosophy and therefore worth discussing briefly at the end.

The $ token always represents a bound value, in the same way that x represents a bound value in print(x). However, you never create a binding to $ yourself. It is automatically inserted into certain contexts. The example above contains two such contexts.

The first is when evaluating the expression on the right hand side of a rebind. Then $ is bound to the value currently bound to the identifier that is being rebound. So equivalent to the second line would be

rebind player : player.scores[1]::$ + 1

The second context is when evaluating the expression after ::. What :: does will be explained soon. For now, here $ is bound to what comes before ::. So we can rewrite further to

rebind player : player.scores[1]::player.scores[1] + 1

The merit of $ is ergonomics. The semantics are bespoke but simple enough to be a basic feature of Lake. I chose $ as the symbol fairly arbitrarily, but a mnemonic is 'it saves you a bunch of typing'.

Onto the second aspect of Lake that enables ergonomic imperative use of nested immutable values, involving ::.

In other languages, typically accessors such as .scores and [1] are like a postfix operator in that they are each evaluated individually, where each evaluation results in a value (where the value could be a reference to an object). E.g. in JavaScript player.scores[0] would be equivalent to ((player).scores)[0].

In Lake, individual accessors are part of an access chain, together with a root. The value that results from evaluating the chain depends on what happens at the end of the chain.

If the chain is not capped by ::, the resulting value of the chain is the nested value, like you'd expect. The chain player.scores[1] in player.scores[1] + 1 is an example of this.

The chain can also be capped by ::, after which an expression is expected. The nested value is replaced by the result of the expression, but the resulting value of the chain (including the cap and right-hand-side expression) is not the updated nested value, but the root. Or rather, because values are immutable, a copy of the root where the accessed nested value has been replaced by the result of the expression to the right of ::.

(As a nicety, not evaluating each accessor individually to obtain intermediate results entirely removes the need for 'optional chaining', such as ?. in JavaScript.)

Having discussed all the pieces, let's take another look at

rebind player : $.scores[1]::$ + 1

You could intuit $ as 'the thing to its left is repeated here', but achieved with clear semantics instead of syntactic shenanigans (which I tried before and is a nightmare). With a moderate amount of familiarity you will likely barely see the$s, in a good way.

Finally the aspect of the example that is not related to imperative use of immutable nested values. Let's take another look at the example in its entirety:

bind player : @.[scores : [99, 50, 25]]
rebind player : $.scores[1]::$ + 1

Lake is dynamically typed, and has only two compound types: lists and (unordered) maps. With syntactic sugar you can ergonomically write lists and maps in such a way that you can use them like types that commonly exist in other languages.

E.g. strings are lists that consist entirely of characters. Writing "foo" is syntactic sugar for ['f', 'o', 'o']. "" is sugar for [].

Likewise, @.[scores : [99, 50, 25]] is sugar for @["scores" : [99, 50, 25]] and player.scores is sugar for player@["scores"]. Syntax highlighting helps make clear what @. and . are sugar for by giving the appropriate keywords the same color as string content, as demonstrated by the following example:

bind name : "Jeroen"
bind user : @.[name : name]
print(user.name)

Note that the most verbose version of

rebind player : $.scores[1]::$ + 1

would be

rebind player : player@["scores"][1]::player@["scores"][1] + 1

Some more pseudotypes (= is the equality operator, used here only to show equality):

@set["a", "b", "a"] = @["a" : 2, "b" : 1]
@enum[LEFT, RIGHT] = @["LEFT" : "LEFT", "RIGHT" : "RIGHT"]
A = 1
AA = 27
A2 = [A, 2]

Lake makes use of syntactic sugar for pseudotypes instead of having distinct types to squeeze more juice out of a single function or operator, without having to do overloading (which I want to keep to severe minimum). If it works on a map, it works on a 'set'. If it works on a list, it works on a 'string'.

The big downside of pseudotypes is that when representing a value as a string, it will always be represented as the actual type. E.g. print(@.[age : 100]) will print @["age" : 100]. Note that this already shows the sole exception: if a list contains only characters, it will be printed in the string format. A quirk of Lake is that the empty string is printed as []. In practise, this is fine. You might consider this a feature: you are reminded that although you wrote a pseudotype literal, you actually just wrote a literal of an actual type. If you insist on stringifying a value in its pseudotype form, you can use a standard library function to do so.