Skip to content

Instantly share code, notes, and snippets.

@elliotchance
Last active September 10, 2025 01:37
Show Gist options
  • Save elliotchance/62a1e4f1d3ec80379d16a3c0a9fbb9d2 to your computer and use it in GitHub Desktop.
Save elliotchance/62a1e4f1d3ec80379d16a3c0a9fbb9d2 to your computer and use it in GitHub Desktop.
Koi

Koi

Koi is a language that seeks to make many traditional bugs impossible by preventing them at the language level. Each of these are discussed in more detail below.

  1. Prevent all runtime errors. Runtime errors are, by definition, unexpected and either have to be caught and emit and error under a sitution that can't be handled safely or cause the program to blow up.
  2. No garbage collector, but no manual memory management either. It's unsafe to manage memory manully, but we also don't want the overhead of a garbage collector.
  3. Interoperability with C. This is critical to making sure we can use existing libraries and also makes the lanaguage a lot easier if we can offload the lowest level logic to C.
  4. All objects are also interfaces. Any object can be provided for a type if it fits the receiving interface.
  5. First class testing. Language constructs for dealing with tests, assertions, mocks, etc.
  6. Sum types handle behaviors like errors.

Language Spec

Types

  • Number
  • String

And some more specific domained types:

type PositiveNumber = Number where value > 0

type Int = Number where math.floor(value) == value
type PositiveInt = Number where math.floor(value) == value and value > 0

Avoding Runtime Errors

There is a differnce between detecting and avoiding. We have to be careful avoiding doesn't become a burdon.

  1. Overflow and underflow.
  2. All matching must be exhaustive?
  3. Array and map out of bounds.
  4. Casting to an invalid type.
  5. Signals and other interupts.

Nil Dereferencing

This is not possible becuase there is no concept of nil/null values because all values are instantiated. There are times when a missing value is needed, you must use a sumtype for this:

type LinkedListNode<T> {
  value T
  next LinkedListNode<T> | None
}

Dividing By Zero

The denominator must be a NonZeroFloat

type NonZeroFloat Float where x != 0

func main() {
  var a = 7 / 5   // OK
  
  var b = 3
  var c = 7 / b   // ERROR: divide expects NonZeroFloat for denominator, got Float
  
  var d = 7 / NonZeroFloat(b)   // ERROR: does not catch DomainErr
  
  var e = 7 / NonZeroFloat(b) -> DomainErr { 1 }   // OK
  
  var f = 0
  if b != 0 {
    f = 7 / b   // OK
  }
}

Casting To Int

Force a rounding mode rather than just truncating?

func main() {
  var a = 1.23
  var b = a as Int   // ERROR: cannot cast Float to Int
  
  var c = math.floor(a)   // OK
}

NaN and Infinity

Rather than make NaN and +/- inf special values, these are actually types that must be handled separately if the function possibly returns them.

// math package

func log(x Float) (Float | Infinity | NotANumber) {
  if x == 0 {
    return Infinity{negative: true}
  }
  
  if x < 0 {
    return NotANumber{}
  }
  
  return C.log(x)
}

func main() {
  io.printLine(log(8))    // 0.903089987
  io.printLine(log(0))    // -Inf
  io.printLine(log(-1))   // NaN
  
  var result = log(8) * 2 // ERROR: log() may return multiple types
  
  // Or, translate other types into a Float
  var result = log(8) as {
    NotANumber, Infinity: 0
  } * 2
}

However, this would be quite painful to do everytime. Especially when we know the inputs are valid, so instead we can use a checked type:

type PositiveFloat Float where value > 0

func log(x PositiveFloat) Float {
  return C.log(x)
}

func main() {
  var result = log(8) * 2   // OK
  
  result = log(-1) * 2      // ERROR: -1 is not a valid PositiveFloat
  
  sneaky = -2
  result = log(sneaky) * 2  // ERROR: expected PositiveInteger, got Int
  
  safeSneaky = PositiveNumber(sneaky + 4) // ERROR: PositiveNumber() may fail check
  
  safeSneaky = PositiveNumber(sneaky + 4) on Domainrr { 1 }   // OK
  log(safeSneaky)
}

Errors

  • DomainErr when trying to cast value into a compatible type that's not allowed.

Avoiding Logic Errors

  1. Explicit order of operations.
  2. Zero out memory.
  3. Explicit mutability.
  4. No jumps/gotos (including breaking).
  5. No operator overloading.
  6. No type overloading.

Processes

Memory cannot be shared between processes. Launching a process returns a different type (ie. Process[MyObject]) that itself provides the API for syncronizing calls. Any value that attempts to cross a process boundary must implement a Copy interface.

Memory Management

Reference counting.

Types and Domains

All Objects Are Interfaces

Testing

  • Tests
  • Assertions
  • Mocks

Language Constructs

Data Types

Variables

Functions

Sum Types

type NumberOrString = number | string

type MultiValues = (number, bool) | None

type NamedTypes = @high (number, number) | @low (number, number) | number
func static[doStuff] :good number | :bad number | error {

}

func {
  const result = match static[doStuff] {
    :good n number {
      n + 5
    }
    :bad number {
      -1
    }
    error {
      0
    }
  }
}

Objects

Control Flow

Errors

Erorrs are just a return type. Auto snapshotting?

Package Management

By Example

Hello World

import io

func main() {
    io.printLine("hello world")
}

Values

// FIXME

import io

func main() {
    io.printLine("go" + "lang")

    fmt.Println("1+1 =", 1+1)
    fmt.Println("7.0/3.0 =", 7.0/3.0)

    fmt.Println(true && false)
    fmt.Println(true || false)
    fmt.Println(!true)
}

Variables

import io

func main() {
    var a = "initial"
    io.printLine(a)

    var b, c int = 1, 2
    fmt.Println(b, c)

    var d = true
    fmt.Println(d)

    var e int
    fmt.Println(e)

    f := "apple"
    fmt.Println(f)
}

Memory Management

There is no dynamic allocation, memory is prealloacted on the stack so there is no need or cost for garbage collection.

func addNumbers(a int, b int) {
  return a + b
}

We only need to advance the stack for the params plus final register (12 bytes). When the return happens, the return register is copied back and the stack pointer moved back.

This created an obvious problem with dynamic memory. Consider a function where we need to filter an array where the final result size is unknown.

Approach 1: first (crude) method might be to allocate something that you know is always going to be large enough:

func filter(list []int) {
  result = new []int(100) // 4x 100 bytes
  for item in list {
    if item != 0 {
      result.append(item)
    }
  }
  
  return result
}

It's not a big concern if the array is too large since the stack point is just rolled back so this only lives for a moment. It's more of a problem if you pick a numbet thats too small. The 101th element will result in an error.

Approch 2: Have the caller specify the size

Provide a value that can be resolved before the function is called so that all size can be known before the function starts:

func filter[size](list []int) {
  result = new []int(size)
  for item in list {
    if item != 0 {
      result.append(item)
    }
  }
  
  return result
}

// Called with filter[len(list)](list)

This is a little better as we can ensure we also have enough, but it still suffers from a bug where maybe as the code changes overtime we need at least that much memory, so we still hit issues and also is a leaky abstraction.

Approach 3: Resizing the stack

Let's say we keep the intial result with zero elements, but increase as we go.

func filter[size](list []int) {
  result = new []int(0)
  totalItems = 0
  
  for item in list {
    if item != 0 {
      result.append(item)
      ++totalItems
    }
  }
  
  return result
}

if result needs to expand, we literally move the memory from totalItems a certain amount of bytes forward. Moving memory (even a lot of it) is really cheap on modern hardware and this would only happen on traditional "alloctions". For example, we don't need to resize the array on every append, we can double the capacity each time to make this an even rarer occurance as it grows larger.

type Age is Integer or None

func Person(name String) {
  Person.name = name
  Person.age = Age(None)
}

func Person.sayHello() {
  io.Print("Hello, {Person.name}")
  
  if Person.age is not None {
    io.Print("You are {Person.age} years old")
  }
}

Enums

enum Colors {
  Red = 1
  Green = 2
  Blue = 3
}

Enums with the same value will return an error, like:

enum Colors {
  Red = 1
  Green = 1 // ERROR
  Blue = 3
}

For two enums to share the same value intentionally, group them:

enum Colors {
  Red, Green = 1
  Blue = 3
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment