Types and behaviour
What lippy's values are and how they behave. A companion to the Syntax reference; start with the Guide if you're new.
The types
Seven of them — the whole list fits in your head:
| Type | Literals | Notes |
|---|---|---|
| number | 42, 3.14 |
one numeric type, no int/float split |
| string | "hello, {name}", `raw text` |
immutable; interpolation built in |
| bool | true, false |
|
| nothing | nothing |
the absence of a value |
| list | [1, 2, 3] |
mutable, ordered, 1-based |
| map | {"key": "value"} |
mutable, string keys, keeps insertion order |
| function | fun greet(who) … end |
defined, not written inline; a value like any other |
A range (1 to 100) is expression syntax for loops and functions like random — not a
type you store. reply "not found", 404 is similar: a value serve understands, made and
handed straight back. It takes an optional third argument for the kind of answer —
reply page, 200, "html" serves a rendered page as HTML.
Functions are values
A function has no literal form — you get one by defining it with fun and naming it. The
name is an ordinary lookup, so a function can be assigned, stored, passed and returned:
fun greet(who)
return "hello, {who}"
end
g is greet # the function itself, not a call
say g # prints: fun greet
say g("world") # prints: hello, world
routes is {"/": greet} # functions live in lists and maps like anything else
The one rule: a lone name calls the function when it stands alone as a whole statement
(beep), because there's nothing else it could mean there. Everywhere else a name is just
a lookup, so b is beep stores the function.
Built-in functions are values too — sort names by len, f is upper, and passing len
to your own function all work. The catch is the same as for your functions: say now
prints fun now, not the time; calling still needs the parens, now().
Numbers
One numeric type: a 64-bit float, like Lua and JavaScript. Whole numbers stay exact up to about ±9 quadrillion — more than any script will meet.
-
Display is smart: a whole number prints without a decimal point —
say 10 / 5prints2, not2.0. Numbers show in the shortest form that reads back to the same value. -
The usual float caveat applies:
0.1 + 0.2 equals 0.3isfalse. For money, work in pennies. -
number("42")converts a string; on something that isn't a number it stops the script with an error naming the value ("forty" is not a number). Loud beats a silentnothing. -
is_number(x)asks the gentle version of the same question — wouldnumber(x)work? — so a script can check instead of being stopped:answer is ask "your guess? " if not is_number(answer) then say "that's not a number — have another go" skip end guess is number(answer) -
Everyday arithmetic:
round(x)/round(x, 2),abs(x),min,max,sum(xs).min/maxtake two values or a list, and order numbers or strings.sum([])is0. -
Dates are numbers-adjacent:
timestamp()is seconds,date_of(seconds)turns one back into a date, anddays_ago(n)is the date n days back.
Truthiness
if x, not x, and, or, stop when x all use one table. Empty things are false:
| Falsy | Truthy |
|---|---|
false |
true |
nothing |
any other number (including negatives) |
0 |
any non-empty string (including "false"!) |
"" |
any non-empty list or map |
[], {} |
functions, results, everything else |
This is what shell scripts want: if not folder then catches both "no argument given"
(nothing) and "empty argument given" ("") in one line.
and/or short-circuit and return the deciding value, so name is args at 1 or "default"
works — though the style to reach for is if and truthiness.
Equality
equals never coerces — the JavaScript "1" == 1 trap doesn't exist here:
- Different types are simply not equal —
1 equals "1"isfalse, no error. - Numbers, strings, bools compare by value.
nothing equals nothingistrue.- Lists and maps compare by contents, deeply:
[1, 2] equals [1, 2]istrue.
Ordering
greater than, less than, at least, at most:
- numbers compare numerically; strings compare alphabetically
(
"apple" less than "banana"). - comparing across types, or ordering lists/maps/bools/nothing, stops the script with
an error showing both values and their types. There's no answer to
5 less than "hat", so lippy refuses to invent one.
Reaching into strings
s at n is the nth character, slice(s, from, to) a substring, index_of(s, part) the
position of a part (nothing if absent), repeat(s, n) the string n times. All 1-based,
inclusive, and counted in characters, not bytes — "café" at 4 is "é", and len
agrees.
Strings don't add
+ is for numbers only. Building strings has one obvious way — interpolation:
say "found {count} files in {folder}" # yes
greeting is "hello, " + name # error: + is for numbers
# hint: try "hello, {name}"
The place interpolation gets in the way is text that contains braces or backslashes — that's what raw strings are for.
Mutability and copying
- Strings are immutable.
upper(name)returns a new string; nothing edits in place. - Lists and maps are mutable —
push item to keepreally appends tokeep. - Assignment shares, it doesn't copy: after
b is a, pushing tobchangesatoo, because they're the same list. When you want an independent one, writeb is copy of a.
nothing, missing things, and when lippy stops you
The rule: absence of data is normal; a wrong program is not.
| Situation | Result | Why |
|---|---|---|
services at "nope" (missing map key) |
nothing |
absence is data |
args at 9 (past the end of a list) |
nothing |
scripts probe arguments |
first of [] |
nothing |
same reason |
| using an undefined variable | error + did-you-mean | that's a typo, not data |
number("forty") |
error naming the value | garbage in should be loud |
5 less than "hat" |
error showing both types | there's no right answer |
| arithmetic on non-numbers | error + hint | + is for numbers |
To tell "missing" from "set to nothing", ask with contains: if services contains "gitea" then (on maps contains asks about keys; on strings and lists, about contents).
attempt — when a stop should be a result
attempt catches failures caused by the world — a missing file, bad JSON, a command
that failed — and hands back a result instead of stopping. It never catches mistakes in
the program (a typo, a wrong argument count); those stay loud, which is the point.
r is attempt read(path) # the file might not be there
r is attempt from_json(read(path)) # the JSON might be rubbish
r is attempt capture("systemctl is-active gitea")
if not ok of r then
say "carrying on without it: {why of r}"
end
It gives back {ok, value, why} — did it work, what did it give, and if not, what lippy
would have said. When the other end answered with a number — a command's exit code, an
HTTP status — there's also a code:
r is attempt fetch(url)
if not ok of r and code of r equals 404 then …
Read the result in two halves: ok and value describe the success; why and code
describe the failure. A success has no failure to describe, so it carries no code.
attempt needs parens around the call it guards — attempt capture("date"), not
attempt capture "date" — because a paren-free call can't contain another one.
Maps keep their order
for name, what in services visits entries in the order they were added — every run, on
every machine. Deterministic order means a script behaves the same on your laptop and your
server, and it's one less idea to learn.