Safety
Lisette guards against Go runtime errors at compile time.
Absent values
Section titled “Absent values”Go does not distinguish between nilable and non-nilable types.
var ages map[string]intages["michael"] = 30panic: assignment to nil mapLisette has no nil and models absence in the type system.
✕ 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
Nil pointers
Section titled “Nil pointers”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)}// fn find(id: int) -> Option<Ref<Person>>
match find(1) { Some(person) => fmt.Println(person.name), None => fmt.Println("no person"),}See References
Nil maps
Section titled “Nil maps”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"),}See Map
Nil interfaces
Section titled “Nil interfaces”A Go interface may be nil, and calling methods on it panics.
var h http.Handlerh.ServeHTTP(w, r)panic: nil pointer dereferenceThere 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 = nilvar h http.Handler = ph != niltrue, the interface has a typeh.ServeHTTP()panic: the value inside is nilLisette 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"),}Zero values
Section titled “Zero values”Go zero-values an uninitialized variable, which blurs the difference between set and unset.
var count int0var name string""var ready boolfalseIn Lisette, every binding must be initialized.
let count = 0let name = ""let ready = falseFor 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 nils.Logger.Print("ready")panic: nil pointer dereferenceIn Lisette, every field must be initialized.
✕ 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 0scores["bob"]0, and bob is missingIn Lisette, Map.get separates absence from default value.
let stored = scores.get("alice")Some(0)let absent = scores.get("bob")NoneSee Struct instantiation and Map
Bad indexes
Section titled “Bad indexes”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"),}Unhandled cases
Section titled “Unhandled cases”Dropped errors
Section titled “Dropped errors”Go allows ignoring errors from fallible operations.
func readConfig(path string) (Config, error) { bytes, _ := os.ReadFile(path)error ignored with _ return parseConfig(bytes)}Lisette flags an unhandled Result.
▲ Result is silently discarded ╭─[example.lis:10:3] 9 │ fn read_config(path: string) -> Config { 10 │ os.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
Non-exhaustiveness
Section titled “Non-exhaustiveness”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}Lisette requires match to be exhaustive.
✕ match is not exhaustive ╭─[example.lis:4:3] 1 │ enum Severity { Low, High, Critical } 2 │ 3 │ fn should_alert(s: Severity) -> bool { 4 │ match 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
Panicking assertions
Section titled “Panicking assertions”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}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)}See Unknown
Unintended writes
Section titled “Unintended writes”Silent mutation
Section titled “Silent mutation”Go does not signal that a function may mutate the caller’s data.
nums := []int{3, 1, 2}sort.Ints(nums)mutates numsLisette makes write permission part of the type.
fn total(items: Slice<int>) -> intcannot write to itemsfn fill(items: mut Slice<int>)may write to itemsLisette knows which Go functions write to their arguments.
✕ 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
Mutable bindings
Section titled “Mutable bindings”Go’s bindings are mutable by default, so they may change unexpectedly.
timeout := config.Timeoutnothing marks it as changeabletimeout = 30Lisette bindings are immutable, unless marked otherwise.
✕ Immutable variable ╭─[example.lis:7:3] 6 │ let timeout = config.timeout 7 │ timeout = 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}In Lisette, a method that writes must declare self writable.
impl Counter { fn increment(self: mut Ref<Counter>) { self.count += 1 }}See Bindings
Aliased collections
Section titled “Aliased collections”In Go, assigning a slice or map copies the handle, not the data.
a := []int{1, 2, 3}b := ab[0] = 99a is now [99 2 3]Lisette copies the handle and its permission. If the original is read-only, so is the new handle.
✕ Cannot write to b[0] ╭─[example.lis:4:3] 2 │ let a = [1, 2, 3] 3 │ let b = a 4 │ b[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
Sub-slicing
Section titled “Sub-slicing”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 3subslice = append(subslice, 99)mutates original[3], giving [1, 2, 3, 99]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 intactSee Slice
Shadowing
Section titled “Shadowing”Go’s := declares and assigns in one step. This can create subtle bugs.
var err errorif condition { x, err := doSomething()declares x, shadows err process(x)}return erralways nilLisette’s let always creates a new binding, and reassignment requires mut.
let result = step1()?declared, cannot be reassignedif result > 0 { let result = step2()?new binding use(result)}let mut result = step1()?declaredif result > 0 { result = step2()?reassigned}See Bindings
Channel hazards
Section titled “Channel hazards”Closed channels
Section titled “Closed channels”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 closedIn Lisette, Channel.receive returns None for closed channels.
match ch.receive() { Some(v) => process(v), None => handle_closed(),}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 channelclose(ch)panic: close of closed channelIn Lisette, send returns false and close is idempotent.
let ch = Channel.new<int>()ch.close()ch.send(42)returns falsech.close()no-opSending inside a select is the exception, and a recover block catches it.
See Channels
Nil channels
Section titled “Nil channels”In Go, sending to or receiving from a nil channel blocks forever.
var ch chan intv := <-chfatal error: all goroutines are asleep, deadlock!In Lisette, a Channel has no zero value.
✕ 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
Channel direction
Section titled “Channel direction”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()See Concurrency
Beyond the basics
Section titled “Beyond the basics”In all, Lisette reports 500+ diagnostics across errors, warnings, and advisories.
wg.Add(1)inside a goroutine can run afterwg.Wait().defer f.Close()in a loop keeps every file open until return.for _, r := range rowsbinds a copy, so writes tornever reachrows.m[key]has nothing to return when the value type has no zero.appendto amake([]T, n)slice leavesnzeros in front.==on an interface holding a slice, map or function panics in Go.os.Exitterminates the process before anydeferruns.cancelfromcontext.WithCancelleaks the context until called.URL.Query()returns a copy in Go, so mutating it changes nothing.