Skip to content

Documentation

joakim edited this page Jan 15, 2024 · 1058 revisions

kesh is what JavaScript (Mocha) might have looked like if it was designed today, without its historical baggage.

[features]: import 'javascript'
[type-system]: import 'typescript'
extensions: import './extensions.kesh'

good-parts: features.filter (part: #feature) -> part.good = true

kesh: essence(good-parts) + essence(type-system) + extensions

Note: This is a work in progress. Nothing is written in stone.

Values

Values are immutable by default.

Expressions

Everything is an expression, except for declarations.

Declarations

Values may be named with :.

answer: 42

Here, the name answer is defined as the value 42, a constant rather than a variable (single assignment).

Naming is not an expression but a declaration. Names are declared within blocks, tuples and collections.

It is possible to redefine a name, but this is opt-in and explicit.

Blocks

A block is a sequence of declarations and expressions, in that order, with lexical scoping and shadowing of names.

It is itself an expression that evaluates to the value of its last evaluated expression.

{
    a: 20
    b: 22
    a + b
}
--> #number 42

For example, a block can be used to produce a value inside a temporary local scope.

a: 1
answer: { a: 20, b: 22, a + b }

a       --> #number 1
b       --> b is not defined (in this scope)
answer  --> #number 42

The outermost scope is always a block. The example above is a block that evaluates to 42.

Blocks are used for text interpolation, computed keys, field access, functions, modules and wherever compound declarations or inline expressions are needed.

Without brackets

Blocks can be written without brackets, using significant indentation instead.

answer:
    a: 20
    b: 22
    a + b

answer  --> #number 42

Primitive data types

There are four primitive data types:

Truth and number values are as described in na, except that numbers are IEEE 754 double-precision 64-bit floating-point values due to the underlying ECMAScript runtime.

Primitive data types have value semantics.

Text

Single-quoted text literals produce verbatim text values.

Double-quoted text literals support escape sequences and interpolation.

single: 'a "verbatim" text'
double: "an \"escaped\" text"

Interpolation

Interpolation is simply inline expressions (blocks) evaluated within a double-quoted text literal.

answer: "The answer is { 20 + 22 }"  --> #text "The answer is 42"
greeting: "Hey, { name }!"           --> #text "Hey, Joe!"

Tagged

Text literals may be tagged with a function.

For example, the function raw may be used to create a text with interpolation but without escape sequences.

path: raw"C:\Users\{ username }\Documents"

See also embedded languages and regular expressions.

Multiline

Multiline single- and double-quoted text literals follow the same rules as Julia's triple-quoted string literals.

hello: '''Hello,
          world!'''
--> #text "Hello,\nworld!"

Symbol

Symbols are human-readable values that are guaranteed to be unique. Symbols are either global or secret.

The naming rules of global symbols are the same as for names. Secret symbols may be given a text description.

global: @cool
secret: @('unique, non-global symbol')  -- can only be referenced by the name it is assigned to

Symbols may be used as hidden fields of collections.

Composite data types

There are two fundamental composite data types:

Tuples

Tuple is the most basic data structure.

A tuple of one value evaluates to that value. An empty tuple evaluates to the unit type #none.

something: (42)  --> #number 42
nothing: ()      --> #none

A tuple of 2+ values is the grouping of those values.

lat: 51.9972
lon: -0.7419
coordinates: (lat, lon)  --> #tuple (51.9972, -0.7419)

A tuple is 0-indexed. Its values are accessed using dot notation.

coordinates.0    --> #number 51.9972
coordinates.1    --> #number -0.7419

Tuples are used for the basic grouping of values, nesting of expressions, function parameter definitions and arguments, multiple return values, the unit type and more.

Due to the underlying ECMAScript runtime, tuples are reference types. Ideally, they would have value semantics.

Collections

Collections represent various data structures with keyed/indexed values.

The [ ] literal creates a plain collection of values. Each value is either keyed or implicitly 0-indexed.

keyed:    [answer: 42]          --> #collection [ answer: 42 ]
indexed:  [1, 2, 3]             --> #collection [ 1, 2, 3 ]
mixed:    [1, 2, 3, length: 3]  --> #collection [ 1, 2, 3, length: 3 ]

Its keys may be names, whole #numbers, #texts or #symbols. Its values may be of any type.

If no key is specified and the value is provided by an identifier, that identifier's name will be used as the value's key.

answer: 42
keyed: [answer]  --> #collection [ answer: 42 ]

If the identifier is provided as a tuple, it will evaluate to its value.

indexed: [(answer)]  --> #collection [ 42 ]

Due to the underlying ECMAScript runtime, name and number keys are evaluated and stored in memory as #text.

[ ] can also represent more advanced data structures, such as ECMAScript's object, array, set, map and more.

Objects

Objects are equivalent to ECMAScript's Object.

ada: object[name: 'Ada', age: 27]
joe: object[name: 'Joe', age: 27]

Objects inherit all its familiar prototype methods.

ada.to-string()
--> #text "[object Object]"

As in ECMAScript, primitive values are automatically boxed as objects when needed, and even functions are (also) objects.

Arrays

Arrays are equivalent to ECMAScript's Array.

As in ECMAScript, an array is an object whose values are implicitly assigned 0-indexed numeric keys.

people: array[ada, joe]
-- > #array [ #object [ … ], #object [ … ] ]

Unlike plain collections and objects, values provided by identifiers will not create keys of those names.

Arrays inherit all the familiar prototype methods from ECMAScript's Array.

people.index-of ada
--> #number 0

Other collection types

All of ECMAScript's collection types are supported – indexed and keyed.

players: set[]
players.add ada
players.add joe

score: map[
    (ada, 22)
    (joe, 20)  -- (key, value) where the key can be of any type
]

Delegation

As a prototype-based language, inheritance is achieved with delegation (single, differential inheritance).

Delegation is done by applying an existing collection to a collection literal, just like function application.

primate: [
    tail: true
]
--> #collection [ tail: true ]

human: primate [  -- delegation
    tail: false   -- overridden value
    walks: true
    talks: true
]
--> #collection [ tail: false, talks: true, walks: true ]

joe: human [
    name: 'Joe'
]
--> #collection [ name: "Joe", tail: false, talks: true, walks: true ]

Conceptually, a collection is (also) a function taking a collection, returning a copy of it with itself applied as prototype.

Composition

An alternative is to use composition within a factory function.

This technique doesn't use prototypal inheritance, and allows fine grained control over private and public fields.

new-person: (options) ->
    [name]: options           -- private field (unpacked from options)
    human: new-human options  -- private field (composition)
    
    say: (words) ->
        human.speak("{ name } says: { words }")  -- no "this" needed
    
    [name, say]  -- public fields returned from function

joe: new-person [
    name: 'Joe'
]

joe
--> #collection [ name: "Joe", say: (words) -> { … } ]

joe.say("Hi!")
--> #text "Joe says: Hi!"

Concatenation

Another alternative is concatenation of collections.

This is achieved by using the spread operator to copy all enumerable fields from an existing collection.

joe: [
    primate...
    human...
    name: 'Joe'
]

Similarly, updated versions of a collection may be created by concatenating values onto the existing collection.

joe-updated: [joe..., name: 'Joey']
people-updated: array[people..., jane]

Hidden fields

Using a symbol as a field's key will make the field non-enumerable.

person: object[
    name: 'Joe'
    @cool: true
]
--> #object [ name: "Joe", @cool: true ]

keys person
--> #array [ "name" ]

It is still accessible by dot notation.

person.@cool
--> #truth true

Computed keys

Collection keys can be specified as an expression by using a 1-tuple (or inline block).

This allows secret symbols to be used as keys. To access the field, the identifier holding the symbol must be in scope.

secret: @('hidden talent')  -- secret symbol

person: object[
    name: 'Joe'
    (secret): 'I can moonwalk!'
]

Field access

A tuple or a collection's values may be accessed using the . operator.

people.0  -- number (index)
--> #object [ name: "Joe", age: 27 ]

person.name  -- name
--> #text "Joe"

person.@cool  -- global symbol
--> #truth true

Fields may also be accessed similar to function application. After all, field access is essentially a getter function.

person.('full name')  -- text
--> #text "Joe Doe"

person.(secret)  -- secret symbol
--> #text "I can moonwalk!"

The bottom prototype is the unit type #none, a collection that only ever returns itself.

Accessing a missing field will therefore not produce an error, but always return #none.

person.foo.bar
--> #none

Unpacking

Tuples and collections may be unpacked, with support for the rest operator.

Tuples are unpacked by index.

(name, age): ('Joe', 27)

Objects are typically unpacked by keys.

[name, age, ...rest]: person

In fact, any collection may be unpacked by keys using [ ].

[length]: array[joe, ada]
length  --> #number 2

Similarly, any iterable collection may be unpacked by iteration using < >.

<first, ...rest>: 'Hey'

Arrays are typically unpacked by iteration (that is, by index).

<head, ...tail>: [1, 2, 3]

Collection keys may be renamed with the as rename operator.

['full name' as name]: person

A default value may be provided with the ? existential operator.

[age ? 0]: person

Functions

Functions are first-class citizens with closure.

All functions have an arity of 1. That single parameter/argument can of course be a tuple of zero or multiple values.

sum: (a, b) -> a + b

The definition of a function's parameter(s) is the unpacking of a tuple or a collection (arguments).

The function's body is an expression, either inline or a block.

greet: [name, age] ->
    name: name if age > 12 else 'kid'
    "Hey, { name }!"

Functions are equivalent to ECMAScript's arrow functions. See also methods.

Application

A function is applied to its argument, which can be a tuple of values.

sum(20, 22)  -- equivalent to: sum (20, 22)
--> #number 42

The argument could also be a previously declared tuple.

numbers: (20, 22)

sum numbers
--> #number 42

Because a 1-tuple is equivalent to its value, a function may be applied to a single value without the use of parens.

joe: [name: 'Joe', age: 27]

greet joe  -- equivalent to: greet(joe)
--> #text "Hey, Joe!"

Function application is right associative.

print greet joe  -- equivalent to: print(greet(joe))

Functions are not hoisted. A function cannot be used before it is defined, logically enough.

Of tuples

Because a function's arguments are unpacked, it can't receive a tuple as a tuple.

To receive its values as one argument, a workaround is to first convert the tuple to an array.

print (1, 2, 3)         --> #text "1 2 3"
print #array (1, 2, 3)  --> #array [1, 2, 3]

Methods

this is an intricate feature of ECMAScript methods that often leads to binding issues. In kesh, this is not a problem.

A method is a collection field that is a function where this is bound to the collection.

byron: [
    name: 'Lord Byron'
    speak(): "Hi, my name is { this.name }!"
]

Unlike ECMAScript, if a method is later detached from its collection, it will still be bound to it.

speak: byron.speak  -- speak is bound to byron
speak()
--> #text "Hi, my name is Lord Byron!"

A method inherited through prototypal delegation is bound to the new collection.

ada: byron [name: 'Ada']  -- ada inherits speak from byron
speak: ada.speak          -- speak is bound to ada, not byron
speak()
--> #text "Hi, my name is Ada!"

Methods are also collections having apply, call and bind methods.

byron.speak.apply [name: 'Ada']
--> #text "Hi, my name is Ada!"

All code is evaluated in strict mode, so this never evaluates to global, even if a function references this without being attached a collection. The compiler should raise a warning in that case, as this would be undefined.

Multiple return values

A function may return multiple values by simply returning a tuple.

sum-diff: (a, b) -> (a + b, a - b)

(sum, diff): sum-diff(100, 42)
sum   --> #number 142
diff  --> #number 58

Variadic functions

Variadic functions are possible with the rest operator.

sum: (...numbers) ->
    numbers.reduce (acc, num) -> acc + num

sum(1, 2, 3, 4)
--> #number 10

Due to the underlying ECMAScript runtime, the rest operator returns an array, not a tuple. Which can be handy.

Side-effecting functions

Functions that perform side effects are defined with \->.

big-red-button: () \-> missile.launch()

A side-effecting function will return #none instead of implicitly returning its last evaluated expression.

The return statement from the return extension may however be used to return a value.

Decorators

Like Python's decorators, only without special syntax. They're simply (factory) functions applied to values.

invincible: (collection) ->
    collection [name: 'invincible']         -- delegation

favorite-color: (color) -> (collection) ->  -- currying
    [collection..., color]                  -- concatentation
black-knight: invincible favorite-color('blue') [
    name: 'The Black Knight'
    color: 'black'
    speak(): "I'm { this.name }!"
]

black-knight.color
--> #text "blue"

black-knight.speak()
--> #text "I'm invincible!"

Conditionals

Conditionals are either a traditional if…(then)…else… construct or the more concise ternary …if…else… operator.

then is only required for the inline form of the traditional construct, which doesn't allow nesting, only else if.

traditional: if age < 13
    'kid'
else if age < 20
    'teenager'
else
    'adult'

inline: if age < 13 then 'kid' else if age < 20 then 'teenager' else 'adult'

ternary: 'kid' if age < 13 else 'teenager' if age < 20 else 'adult'

The condition must evaluate to a #truth value, there's no implicit truthy/falsy.

The coercion and existential operators help with this.

Operators

Equality

Using : for naming means that = is free to be used as the equality operator, which makes sense.

= represents strict equality, where both operands must be of the same type. /= () represents strict inequality.

answer = 42    --> #truth true (strict equality)
answer'42'  --> #truth true (strict inequality)

Similarly, ~= represents loose equality, and ~/= (~≠) loose inequality, with automatic type coercion of operands.

answer ~= '42'  --> #truth true (loose equality)
answer ~≠ '42'  --> #truth false (loose inequality)

Logical

Logical operators use words.

not true
true and false
true or false

Operands must evaluate to a #truth value, there's no implicit truthy/falsy.

The coercion and existential operators help with this.

Coercion

The coercion operator ~ coerces its operand to a #truth value.

It evaluates to true if its operand is truthy and false if it is falsy.

collection: []

print "It is { 'truthy' if ~collection else 'falsy' }"
--> #text "It is truthy"

Existential

The existential operator ? can be used as a unary operator or as a binary operator.

As a trailing unary operator, it evaluates to false if its operand is #none and true for any other value.

if page.title? and page.author?
    -- page has title and author

As a binary operator, it functions as the nullish coalescing operator, evaluating to the right operand if the left is #none.

title: page.title ? 'Default title'

This is logically equivalent to:

title: page.title if page.title? else 'Default title'

Spread and rest

... is used for both spread and rest operators. Unlike ECMAScript, the spread operator must appear after the identifier.

spread: [items..., item]
rest: (...arguments) -> print arguments

Due to the underlying ECMAScript runtime, the spread operator produces a shallow copy only.

Rename

The as operator renames a key when unpacking.

[age as years-old]: ada

It is also used by some extensions, with similar semantics.

Arithmetic

Arithmetic operators require that both operands are of the type #number.

4 + '2'          --> a type error, not "42"
4 + #number '2'  --> #number 6

Text should be concatenated using interpolation.

In addition to the standard arithmetic operators +, -, / and *, kesh also has the following:

  • div for integer division (floored division)
  • mod for modulo (floored division, same sign as divisor)
  • rem for remainder (truncated division, same sign as dividend)
  • pow for exponentiation

Range

The range operator .. creates an inclusive or infinite range of numbers or characters.

numbers: 1..7
letters: 'a'..'z'
infinite-numbers: 1..
infinite-unicode-characters: 'À'..

When a finite range is wrapped in a collection literal, it creates a collection having those values.

numbers: [0..9]
characters: set['a'..'f']

Introspection

inherits tests whether collection A has collection B somewhere in its prototype chain.

joe inherits primate
--> #truth true

is-a tests whether collection A's prototype is collection B.

joe is-a human
--> #truth true

is and isnt test whether a value is of a runtime type.

answer: 42

answer is #number  --> #truth true
answer isnt #text  --> #truth true

has tests whether a collection has a value.

people has joe   -- array
--> #truth true

players has ada  -- set
--> #truth true

ada has 'name'   -- object
--> #truth true

Helper functions

print

An alias for the active runtime's output function. Usually, this is console.log.

A simple Hello World program is therefore:

print 'Hello, world!'

type

Returns the runtime type of the evaluated operand as #text, equivalent to ECMAScript's typeof operator.

type answer
--> #text "number"

When used in a type context, it returns the compile time type, equivalent to TypeScript's typeof operator.

verify: (x) -> x = 42
#return: #ReturnType(type verify)  -- #truth

keys

Returns an array of a collection's own enumerable keys as #text.

keys joe
--> #array [ 'name' ]

If the second argument is true, it also returns any inherited enumerable keys.

keys(joe, true)
--> #array [ 'name', 'tail', 'talks', 'walks' ]

When used in a type context, it is equivalent to TypeScript's keyof operator.

#person: [name: #text, age: #number]
#keys: keys #person  -- "name" | "age"

values

Returns a collection's own enumerable values as an array.

values joe
--> #array [ "Joe" ]

If the second argument is true, it also returns any inherited enumerable values.

values(joe, true)
--> #array [ "Joe", false, true, true ]

fields

Returns a collection's own enumerable fields as an array of (key, value) tuples.

fields joe
--> #array [ ("name", "Joe") ]

If the second argument is true, it also returns any inherited enumerable fields.

fields(joe, true)
--> #array [ ("name", "Joe"), ("tail", false), ("talks", true), ("walks", true) ]

copy

Creates a shallow copy of a collection.

joe2: copy joe

If the second argument is true, it retains the prototype chain.

joe2: copy(joe, true)

joe2 is-a human
--> #truth true

clone

Creates a structured clone of a collection (a deep copy).

joe2: clone joe

If the second argument is true, it also copies the prototype chain.

joe2: clone(joe, true)

joe2 is-a human
--> #truth true

Type system

kesh builds on TypeScript's gradual, inferred, structural type system, with some differences.

Type definition

kesh only has types, denoted by a leading #.

Unlike TypeScript's interfaces, types are immutable and can not be extended.

#string: #text                         -- alias

#point: (#number, #number)             -- tuple

#colorful: [color: #text]              -- object
#circle: [radius: #number]
#colorful-circle: #colorful & #circle  -- intersection

#texts:  #text[]                       -- array, alternatively #array(#text)
#result: #text | #texts                -- union

#greet: (#text) -> #text               -- function

#players: #set(#player)                -- set
#scores: #map(#player, #number)        -- map

Collection types

Collection types may be used as prototypes, as they are at the same time type definitions and actual collections for delegation.

Syntactically, delegation to a collection type is the same as conversion of runtime types.

Conceptually, a collection type is (also) a function taking a collection and applying itself as its prototype. Any literal fields of the collection type is thus inherited through prototypal delegation.

Fields may be marked as optional using the existential operator ?, defaulting to #none.

Alternatively, optional fields may use a trailing existential operator to provide a default value that's compatible with its type.

#primate: [                    -- definition
    tail: #truth               -- type
    name?: #text               -- type (optional field, defaulting to #none)
]

#human: #primate [             -- delegation
    tail: false                -- literal
    name: #text ? 'Anonymous'  -- type (optional field with a default value)
]

joe: #human [                  -- delegation
    name: 'Joe'                -- literal
]

joe.tail
--> #truth false

joe is-a #human
--> #truth true

joe inherits #primate
--> #truth true

An index signature is defined using a valid type as the key.

#texts-and-numbers: [
    #text: #text | #number
    length: #number
    name: #text
]

Type annotation

The type of a name may be declared in advance.

name: #text

--

name: 'Ada'

As in TypeScript, the name cannot be referenced before it has been assigned a value.

Protocols

A collection type can also be a protocol.

#walker-talker: [
    walk: (#number) -> #text
    talk: (#text) -> #text
]

Generics

Generics work just like in TypeScript.

#box<#type>: [
    contents: #type
]

number: #box<#number>: [
    contents: 42
]

text: #box<#text>: [
    contents: 'hello'
]

#identity<#type>: (#type) -> #type
identity: <#type>(x: #type) -> #type { x }
number-id: #identity<#number>: identity

number-id 42  --> #number 42

Special types

  • #dynamic
  • #some
  • #none
  • #never

#dynamic is equivalent to TypeScript's any, disabling type checking. It is discouraged, though necessary in some situations.

#some is the top type, equivalent to TypeScript's unknown, which is a type-safe version of TypeScript's any.

#none is the unit type, conceptually a special collection that only ever returns itself.

#never is the bottom type, equivalent to TypeScript's never.

Type conversion

When used as a function, runtime data types perform type conversion.

truth:   #truth 42     --> #truth true
number:  #number '42'  --> #number 42
text:    #text 42      --> #text "42"
symbol:  #symbol 42    --> #symbol @("42")
tuple:   #tuple 42     --> #number 42
array:   #array 42     --> #array [ 42 ]

Modules

A file is a module is a block.

Exports

Because it is a block, the last evaluated expression of a file is the module's exported value.

To prevent exporting of values by accident, the IDE should highlight a module's last expression.

By convention, the last line is an object of values to export.

-- file: deep-thought.kesh
answer: 42
respond: () -> print answer

now: temporal.now.plain-date-time-ISO()
duration: temporal.duration.from [years: 7500000] -- 7.5 million years
timeout: duration.total [unit: 'millisecond', relative-to: now]

ask: (question: #text) -> set-timeout(respond, timeout)

[ask]  -- exported

Default exports are considered harmful, but can be named explicitly if needed.

[ask, default: answer]

Imports

The import function statically imports another module's exported values.

merge: import 'lodash.merge'

Exported objects may be unpacked upon import.

-- file: asker.kesh
[ask]: import 'deep-thought.kesh'

ask 'What is the answer to the ultimate question of life, the universe, and everything?'

-- 7.5 million years later…
--> #number 42

Any default export must be explicitly (re)named upon import.

[default as lodash]: import 'lodash'

Embedded languages

Compiled programming languages can be embedded within kesh using backticks.

Tagged code literals (combined with extensions) enable the embedding of other compiled programming languages.

TypeScript

Untagged code literals are evaluated as TypeScript/JavaScript.

    ```
    let message: string = 'Hello, world!';
    ```
    
    print message

Regular expressions

See the regular expressions extension.

Extensions

The language core is small by design. Any additional language features must be explicitly enabled using an interpreter directive at the top of the file, specifying both the language version and any features to be enabled or disabled.

kesh 2021 standard

Tying the source code to a specific version enables the language to evolve without risk of breaking backward compatibility. Legacy source code is guaranteed to always run as expected, using the specified version of the language.

The directive may also take the form of a shebang, using /usr/bin/env to resolve the kesh executable.

#!/usr/bin/env kesh 2021 standard

This serves two purposes, as the file can now be easily made executable on *nix systems.

Alternatively, a configuration file may specify the project-wide language version and features.

Some possible extensions are explored in Extensions.

Reserved names

List of reserved names in kesh:

Any keyword may be used as a name, also within blocks. The keyword will no longer be available within that scope.

The IDE or compiler may raise a warning when using a keyword as a name.

Comments

-- this is a line comment

IDE/compiler warnings may be silenced using a hush comment.

noisy code  -- hush

The debugger may be invoked with a debug comment.

problematic code  -- debug