Skip to content

Safety

Lisette guards against Go runtime errors at compile time.

Go does not distinguish between nilable and non-nilable types.

var ages map[string]int
ages["michael"] = 30panic: assignment to nil map
nilmap.go

Lisette has no nil and models absence in the type system.

Terminal window
nil is not supported
╭─[example.lis:7:12]
6 │ if name.is_empty() {
7 │ return nil
· ─┬─
· ╰── does not exist
8 │ }
╰────
help: Absence is encoded with Option<T> in Lisette. Use None to represent absent values · code: [resolve.nil_not_supported]

See Option

Go’s pointer type may or may not be nil. Lisette’s pointer type is always non-nil, while a pointer that may be absent is typed as optional.

// func find(id int) *Person
p := find(1)
if p != nil {
fmt.Println(p.Name)
}
find.go
// fn find(id: int) -> Option<Ref<Person>>
match find(1) {
Some(person) => fmt.Println(person.name),
None => fmt.Println("no person"),
}
find.lis

See References

A Go map may be nil, which panics on write. Lisette reflects this in the incoming type.

// Go: func (h Header) Clone() Header
// Lis: fn Clone(self) -> Option<Header>
match request.Header.Clone() {
Some(headers) => forward(headers),
None => fmt.Println("no headers to forward"),
}
users.lis

See Map

A Go interface may be nil, and calling methods on it panics.

var h http.Handler
h.ServeHTTP(w, r)panic: nil pointer dereference
handler.go

There is also a subtler case: typed nil. A nil pointer assigned to an interface makes the interface non-nil, so the type is known, but the value is nil. Go’s != nil check passes, but calling methods still panics.

var p *MyHandler = nil
var h http.Handler = p
h != niltrue, the interface has a type
h.ServeHTTP()panic: the value inside is nil
handler.go

Lisette wraps a Go interface in Option when it crosses the interop boundary in a position where it could be nil. Both a nil interface and a typed nil interface become None.

// Go: func FindHandler(name string) http.Handler
// Lis: fn FindHandler(name: string) -> Option<http.Handler>
match FindHandler("api") {
Some(h) => router.Handle("/api", h),
None => fmt.Println("no handler"),
}
handler.lis

See Nil Go interfaces

Go zero-values an uninitialized variable, which blurs the difference between set and unset.

var count int0
var name string""
var ready boolfalse
zero.go

In Lisette, every binding must be initialized.

let count = 0
let name = ""
let ready = false
zero.lis

For struct fields, zero values risk failure at runtime.

type Server struct {
Handler http.Handler
Logger *log.Logger
DB *sql.DB
}
s := Server{Handler: mux}Logger and DB are nil
s.Logger.Print("ready")panic: nil pointer dereference
server.go

In Lisette, every field must be initialized.

Terminal window
Struct Server is missing fields
╭─[example.lis:9:11]
8 │ let mux = 1
9 │ let s = Server { handler: mux }
· ───┬──
· ╰── missing fields: db, logger
10 │ }
╰────
help: Initialize all fields, or add .. to autofill the rest · code: [infer.missing_struct_fields]

A map lookup blurs similarly.

scores := map[string]int{"alice": 0}
scores["alice"]0, and alice is 0
scores["bob"]0, and bob is missing
scores.go

In Lisette, Map.get separates absence from default value.

let stored = scores.get("alice")Some(0)
let absent = scores.get("bob")None
scores.lis

See Struct instantiation and Map

An out-of-range index panics in Go. Slice.get and Array.get return an Option instead.

match items.get(7) {
Some(item) => fmt.Println(item),
None => fmt.Println("out of range"),
}
items.lis

See Slice and Array

Go allows ignoring errors from fallible operations.

func readConfig(path string) (Config, error) {
bytes, _ := os.ReadFile(path)error ignored with _
return parseConfig(bytes)
}
config.go

Lisette flags an unhandled Result.

Terminal window
Result is silently discarded
╭─[example.lis:10:3]
9 │ fn read_config(path: string) -> Config {
10os.ReadFile(path)
· ────────┬────────
· ╰── failure will go unnoticed
11 │ parse_config()
╰────
help: Handle this Result with ? or match, or explicitly discard it with let _ = ... · code: [lint.unused_result]

Lisette omits Rust’s unwrap().

See Failures

Go tolerates missing cases in switch statements.

type Severity int
const (
Low Severity = iota
High
Critical
)
func shouldAlert(s Severity) bool {
switch s {
case Low:
return false
case High:
return true
}
return falseCritical produces no alert
}
severity.go

Lisette requires match to be exhaustive.

Terminal window
match is not exhaustive
╭─[example.lis:4:3]
1 │ enum Severity { Low, High, Critical }
2
3 │ fn should_alert(s: Severity) -> bool {
4match s {
· ───┬───
· ╰── not all patterns covered
5 │ Low => false,
6 │ High => true,
7 │ }
8 │ }
╰────
help: Handle the missing case Critical, e.g. Critical => { ... } · code: [infer.non_exhaustive]

See Pattern matching

In Go, a type assertion can panic, and the ok check is opt-in.

func getRequestID(ctx context.Context) string {
val := ctx.Value("request_id")
str := val.(string)
panics if not string
return str
}
request.go

In Lisette, Unknown is narrowed safely.

fn get_request_id(ctx: context.Context) -> Option<string> {
let raw_id = ctx.Value("request_id")
Unknown type
let id = assert_type<string>(raw_id)?
either id is string, or None propagates
Some(id)
}
request.lis

See Unknown

Go does not signal that a function may mutate the caller’s data.

nums := []int{3, 1, 2}
sort.Ints(nums)mutates nums
sort.go

Lisette makes write permission part of the type.

fn total(items: Slice<int>) -> intcannot write to items
fn fill(items: mut Slice<int>)may write to items
permission.lis

Lisette knows which Go functions write to their arguments.

Terminal window
Immutable variable
╭─[example.lis:5:13]
4 │ let nums = [3, 1, 2]
5 │ sort.Ints(nums)
· ──┬─
· ╰── nums was declared without mut
6 │ }
╰────
help: sort.Ints() writes to nums. Declare using let mut nums to mark the variable mutable · code: [infer.immutable]

See Write permission in parameters

Go’s bindings are mutable by default, so they may change unexpectedly.

timeout := config.Timeoutnothing marks it as changeable
timeout = 30
process.go

Lisette bindings are immutable, unless marked otherwise.

Terminal window
Immutable variable
╭─[example.lis:7:3]
6 │ let timeout = config.timeout
7timeout = 30
· ──────┬─────
· ╰── timeout was declared without mut
8 │ }
╰────
help: Declare using let mut timeout to mark the variable mutable · code: [infer.immutable]

The same applies to method receivers.

func (c Counter) Increment() {
c.count++mutates copy, original intact
}
counter.go

In Lisette, a method that writes must declare self writable.

impl Counter {
fn increment(self: mut Ref<Counter>) {
self.count += 1
}
}
counter.lis

See Bindings

In Go, assigning a slice or map copies the handle, not the data.

a := []int{1, 2, 3}
b := a
b[0] = 99a is now [99 2 3]
share.go

Lisette copies the handle and its permission. If the original is read-only, so is the new handle.

Terminal window
Cannot write to b[0]
╭─[example.lis:4:3]
2 │ let a = [1, 2, 3]
3 │ let b = a
4b[0] = 99
· ────┬────
· ╰── b is read-only
5 │ }
╰────
help: b shares storage with a, which is read-only. Make a writable, or write to a .clone() for an independent copy · code: [infer.write_through_read_only]

The guarantee is “no unmarked mutation” (explicitness), not “only one writer” (exclusivity). No write should happen without mut in the type, but two mut values may share storage.

See Write permission in bindings

In Go, append on a sub-slice may mutate the original, depending on capacity at runtime.

original := []int{1, 2, 3, 4}
subslice := original[1:3]subslice is [2, 3], capacity 3
subslice = append(subslice, 99)mutates original[3], giving [1, 2, 3, 99]
subslice.go

In Lisette, append on a sub-slice always allocates, so the original never changes. Sub-slicing stays free.

let original = [1, 2, 3, 4]
let subslice = original[1..3]
let subslice = subslice.append(99)original is intact
subslice.lis

See Slice

Go’s := declares and assigns in one step. This can create subtle bugs.

var err error
if condition {
x, err := doSomething()declares x, shadows err
process(x)
}
return erralways nil
bindings.go

Lisette’s let always creates a new binding, and reassignment requires mut.

let result = step1()?declared, cannot be reassigned
if result > 0 {
let result = step2()?new binding
use(result)
}
let.lis
let mut result = step1()?declared
if result > 0 {
result = step2()?reassigned
}
let_mut.lis

See Bindings

In Go, a closed channel silently yields the zero value, and the ok check is opt-in. A zero can pass for a sent value.

ch := make(chan int)
close(ch)
v := <-chv is 0, no indication ch is closed
closed.go

In Lisette, Channel.receive returns None for closed channels.

match ch.receive() {
Some(v) => process(v),
None => handle_closed(),
}
closed.lis

Go also panics if you send to a closed channel, or if you close an already closed channel.

ch := make(chan int)
close(ch)
ch <- 42panic: send on closed channel
close(ch)panic: close of closed channel
panic.go

In Lisette, send returns false and close is idempotent.

let ch = Channel.new<int>()
ch.close()
ch.send(42)returns false
ch.close()no-op
send_close.lis

Sending inside a select is the exception, and a recover block catches it.

See Channels

In Go, sending to or receiving from a nil channel blocks forever.

var ch chan int
v := <-chfatal error: all goroutines are asleep, deadlock!
nil_channel.go

In Lisette, a Channel has no zero value.

Terminal window
Missing initializer
╭─[example.lis:2:11]
1 │ fn main() {
2 │ let ch: Channel<int>
· ──────┬─────
· ╰── annotated binding needs a value
3 │ let v = ch.receive()
╰────
help: Bindings must be initialized · code: [parse.missing_initializer]

A Go channel that may be nil arrives as an Option. Inside a select, a nil channel keeps its Go meaning, i.e. that arm is never ready.

See Channels

Any Go channel can be sent to, received from, and closed. Closing it while a producer is still sending panics. Go’s directional types chan<- T and <-chan T are opt-in.

In Lisette, channel direction is encoded in the type.

let (tx, rx) = Channel.new<int>().split()
task send_jobs(tx)Sender<int> can only send() and close()
run_jobs(rx)Receiver<int> can only receive()
split.lis

See Concurrency

In all, Lisette reports 500+ diagnostics across errors, warnings, and advisories.

  • wg.Add(1) inside a goroutine can run after wg.Wait().
  • defer f.Close() in a loop keeps every file open until return.
  • for _, r := range rows binds a copy, so writes to r never reach rows.
  • m[key] has nothing to return when the value type has no zero.
  • append to a make([]T, n) slice leaves n zeros in front.
  • == on an interface holding a slice, map or function panics in Go.
  • os.Exit terminates the process before any defer runs.
  • cancel from context.WithCancel leaks the context until called.
  • URL.Query() returns a copy in Go, so mutating it changes nothing.