Introduction
Goblin is a dynamically typed language with Go-style braces and a compact, Python-like feel. It is useful for experimenting with programs and for learning how interpreters, runtimes, and code generation fit together.
This book assumes that you can use a command line and already know the basic ideas of variables, functions, and control flow. Its aim is not to catalogue every API, but to get you comfortably reading and writing Goblin programs.
Goblin has two ways to run a program:
goblin runinterprets a source file directly. Use it while developing and experimenting.goblin build-exetranspiles source to Go and compiles a native executable. Use it when you want a standalone program.
Both paths parse the same language and run semantic checks before executing.
During development, start with goblin run; use build-exe when you need an
executable without the Goblin CLI. The generated executable still uses the
Goblin runtime, so the language behavior is intended to be the same.
Here is a complete Goblin program:
func greet(name) {
print("Hello,", name)
}
for name in ["world", "Goblin"] {
greet(name)
}
Source files use the .goblin extension. Comments start with #. When you
are ready, continue with Installation.
What this book covers
The chapters first cover values and control flow, then functions and
collections, followed by custom types, modules, and errors. The examples are
complete fragments that can be pasted into a .goblin file. The Book focuses
on the language and its standard runtime; see the repository's examples/
directory for larger executable programs.
For the current implementation boundaries and features deliberately absent from the language, see Language model and limits.
Language model and limits
Goblin is a dynamically typed language with a tree-walking interpreter and a
Go transpilation backend. Both backends use the same parser, semantic checks,
and runtime object model. Use goblin run for iteration and goblin build-exe
when a standalone executable is needed.
The language is intentionally small. These boundaries are useful to know before designing a larger program:
| Area | Current behavior |
|---|---|
| Types | Dynamic; functions and fields have no annotations |
| Functions | Required and defaulted parameters plus *args and **kwargs |
| Strings | Unicode-aware iteration and size; no string[index] syntax |
| Lists | Direct indexes are non-negative; some methods such as pop() accept negative indexes |
| Imports | Module scope only; local paths are relative to the importing file |
| Concurrency | Goroutines, channels, and joinable Goblin handles; no select or cancellation |
| Errors | Explicit raise and try/catch; spawned-function errors are not propagated |
Goblin favors direct, explicit code over a large amount of syntax. When a feature is absent, compose the available pieces: use a channel instead of a join operation, or an explicit loop instead of a specialized expression form.
Choosing a backend
Start with goblin run while developing. It gives direct source tracebacks and
does not need to invoke Go. Use goblin build-exe only once the program works;
it generates Go code and requires a working Go toolchain. A generated executable
still uses Goblin's runtime behavior, rather than turning a Goblin value into a
native Go primitive everywhere.
Installation
Goblin is installed through the Go toolchain. Install Go 1.20 or later, then verify that it is available:
$ go version
Install with go install
$ go install github.com/aisk/goblin@latest
This downloads, builds, and installs goblin into $GOBIN, normally
$HOME/go/bin when GOBIN is unset. Ensure that directory is on your PATH.
For a typical Unix shell, add the following line to your shell profile if
goblin is not found after installation:
export PATH="$PATH:$(go env GOPATH)/bin"
Verify the installation
$ goblin --help
If help text is printed, the installation is ready to use.
The CLI provides three subcommands:
| Command | Purpose |
|---|---|
goblin run file.goblin [args...] | Interpret a source file (trailing args become os.argv(); put the file before any flags) |
goblin build-exe file.goblin | Build a native executable |
goblin repl | Start an interactive session |
For goblin run, CLI help is goblin run -h or goblin help run. Script
flags such as -v must come after the source file.
Build from source
Alternatively, clone the repository and build it yourself:
$ git clone https://github.com/aisk/goblin.git
$ cd goblin
$ go build .
This produces a goblin executable in the current directory. While working
from a clone of the repository, you can also run it directly:
$ go run . run hello.goblin
Run the project's checks before contributing a change:
$ go test ./...
To verify the complete programs mirrored in the Book, run:
$ bash docs/check-examples.sh
Updating
To update an installation made with go install, run the same command again:
$ go install github.com/aisk/goblin@latest
Your first program
Create hello.goblin:
print("Hello, world!")
To write the same kind of line to stderr (for warnings or diagnostics), use
eprint instead of print.
Goblin does not require a main function. Statements at the top level run in
order. Add a comment with # when you need to explain a line:
# A program may have more than one top-level statement.
var audience = "world"
print("Hello,", audience)
Run it directly
$ goblin run hello.goblin
Hello, world!
run parses and interprets the file. It is the usual command during development.
If parsing, semantic checking, or execution fails, the command reports the
source location and exits with an error.
Compile an executable
$ goblin build-exe hello.goblin
$ ./hello
Hello, world!
By default the output name is derived from the source file. Specify a path with
-o when needed:
$ goblin build-exe -o bin/hello hello.goblin
build-exe requires a working Go toolchain because it invokes go build
after generating Go source. The output path may be absolute or relative to the
current directory.
Use the REPL
Goblin also includes an interactive REPL:
$ goblin repl
>>> print("Hello, world!")
Hello, world!
The REPL saves history. Its prompt changes to ... while you enter a
multi-line function or block. Press Ctrl-D to exit.
Use the REPL for small experiments. Definitions stay available for the rest of the session:
>>> var answer = 42
>>> answer + 1
43
Using the REPL
Start a persistent interactive Goblin session with:
$ goblin repl
Goblin REPL. Press Ctrl-D to exit.
>>>
Expressions print their value automatically; declarations and statements do not. Names, imports, functions, and types stay available until the session exits.
>>> 1 + 2 * 3
7
>>> var values = [1, 2, 3]
>>> values.push(4)
>>> values
[1, 2, 3, 4]
>>> import "math"
>>> math.sqrt(81)
9
Relative imports are resolved from the directory where the REPL starts, so start it from a project directory when testing a local module.
os.argv() in the REPL returns [""]. It does not expose the goblin binary's
process arguments. Use goblin run when you need real script argv.
Multi-line input
When parentheses, brackets, or braces are unbalanced, the prompt changes to
... and the REPL keeps collecting lines. This is how to enter functions,
types, loops, and try/catch blocks.
>>> func square(x) {
... return x * x
... }
>>> square(12)
144
Enter a blank line to force evaluation of a malformed fragment, or press Ctrl-C to discard the input currently being collected.
Completion and history
Press Tab to complete visible names, keywords, and member paths. For example,
after import "json", type json. and press Tab to see members. Completion
only reads attributes; it does not call Goblin functions.
History is stored in ~/.goblin_history; use Up/Down to revisit entries and
Ctrl-D to exit. Use the REPL for small experiments and API discovery, then move
repeatable work into a .goblin file.
Syntax and call rules
Goblin uses braces for blocks and does not use semicolons. A newline ends a
statement, so each statement sits on its own line. Comments begin with #
and continue to the end of the line.
Literals and collection syntax
Integer literals such as 42 and float literals such as 3.14 are decimal
only. Strings use double quotes. They support \n, \t, \r, \", and \\.
Any other escape sequence is a syntax error.
var names = ["Ada", "Linus"]
var user = {"name": "Ada", "active": true}
List, dictionary, call, parameter, and field lists do not accept a trailing comma. Dictionary keys and values are expressions, but keys should be stable values such as strings, integers, or booleans.
Statements and line breaks
Any expression can stand alone as a statement, but only a call, index, or
member access may do so in a program: user.save(), "a,b".split(","), and
func() { ... }() are valid statements, while a + b or a lone - 2 is
rejected with expression value is not used, since a value that is computed
and dropped is almost always a mistake. The REPL is the exception: there a bare
expression such as 1 + 2 is evaluated and displayed.
Because a newline ends a statement, a line can only continue the previous one
in places where the expression is visibly unfinished: after a binary operator,
a comma, a dictionary colon, or inside an open (, [, or { of a literal.
Ending a line with a complete expression and starting the next with an
operator does not continue it. var a = 10 followed by a line - 2 is two
statements, and the second is the unused-value error above rather than a
silent 10 - 2.
var total = price * quantity +
shipping
var discounted = (
total - coupon
)
var files = {
"a.txt": "first",
"b.txt": "second"
}
print(files["a.txt"],
files["b.txt"])
The same rule means else must follow the closing brace of its if on the
same line, and two statements cannot share a line.
Names
Identifiers may not be Go keywords: names such as map, range, select,
struct, go, or defer are reserved and rejected at check time, because
compiled programs emit them as Go identifiers. A handful of names the
generated code itself relies on — object, extension, builtin, fmt,
_registry, Execute, and main — are reserved for the same reason, as is
the whole family of identifiers shaped like _name_N (a leading underscore
with a trailing _<digits> suffix, e.g. _err_0), which the transpiler uses
for its own temporaries.
Built-in function names like print or max are not reserved — see
Scope and declarations for how user declarations shadow them.
Functions and calls
Function parameters are required unless they declare a default value with =
or are captured by *args or **kwargs.
func report(name, limit = 10, *values, **options) {
print(name, limit, values, options)
}
report("scores", 1, 2, visible=true)
Calls can use positional arguments, named arguments, * list expansion, and
** dictionary expansion. Positional arguments must come before named ones.
Whether a particular built-in or method accepts names is API-specific: many
small methods are positional-only. Use the documented parameter names or
value.attributes() in the REPL to discover a value's available methods.
Values, variables, and expressions
Goblin is dynamically typed: variables do not declare a type, and values carry
their types at runtime. Use var to bind a name to a value.
var project = "Goblin Book"
var published = false
Assign to the name to update it later.
var score = 10
score = score + 5
print(score) # 15
print separates multiple arguments with spaces and ends the line; it writes
to stdout. Use eprint for the same formatting on stderr. A comment starts
with # and runs to the end of its line.
Operators
The ordinary arithmetic operators are +, -, *, /, and %. The %
operator returns the remainder and has the same precedence as multiplication
and division. Use parentheses to make grouping explicit.
print(1 + 2 * 3) # 7
print((1 + 2) * 3) # 9
print("go" + "blin") # goblin
print("ha" * 3) # hahaha
print(10 % 3) # 1
Comparison operators are ==, !=, <, <=, >, and >=; each produces a
boolean. Equality is total across types: values of unrelated types are simply
unequal, so x == nil is always a safe test, and lists and dictionaries
compare element by element. Ordering comparisons (<, <=, >, >=) raise
TypeError when the operands cannot be ordered. Comparisons do not chain:
a < b < c is a syntax error rather than the surprising (a < b) < c, so
write a < b && b < c, or parenthesize explicitly when you really mean to
compare a boolean result. Logical operators are !, &&, and ||.
&& and || short-circuit, so their right-hand side is evaluated only when
needed, and they produce the operand that decided the result rather than a
boolean: a || b is a when a is truthy and b otherwise, a && b is a
when a is falsy and b otherwise. That makes || the way to write "use
this, or a default". Wrap the expression in Bool() when you need a plain
true/false. ! always produces a boolean.
var allowed = age >= 18 && !banned
var nickname = supplied || "anonymous"
&& binds tighter than ||, as in most languages: ready || retry && connected evaluates as ready || (retry && connected), and
true || false && false produces true.
Conditions use truthiness. false, nil, numeric zero, and empty strings or
collections are false. Continue
with Built-in types for the values that Goblin provides.
Scope and declarations
var creates a name in the current lexical scope. A name declared in a block
is visible inside nested blocks, but not after the block ends. Assignment
updates the nearest existing name; use var when a new local binding is
intended.
var message = "outside"
if true {
var detail = "inside"
message = "changed"
print(detail)
}
print(message) # changed
# detail is not visible here
Functions capture the surrounding lexical scope. Each for iteration has its
own loop binding, so functions created in a loop retain that iteration's value.
var readers = []
for value in [1, 2, 3] {
readers.push(func() { return value })
}
print(readers[0](), readers[1](), readers[2]()) # 1 2 3
Declarations at module scope
import, type, and export are allowed only at module scope. Module-level
import, func, and type names are hoisted: they are visible throughout
the module regardless of where the definition appears, so functions may call
functions defined later — including mutually recursive pairs.
func is_even(n) {
if n == 0 { return true }
return is_odd(n - 1)
}
func is_odd(n) {
if n == 0 { return false }
return is_even(n - 1)
}
print(is_even(10))
Other names must be declared before use: local var declarations (and nested
function definitions) are not available before their declaration. Keep
declarations near the beginning of the block when that makes a function easier
to read.
Shadowing built-in functions
A user declaration may reuse a built-in function's name. The innermost binding wins, and the built-in becomes visible again once the shadowing scope ends:
func min(a, b) {
return "user min"
}
print(min(1, 2)) # user min
if true {
var max = 100
print(max) # 100
}
print(max(1, 2)) # 2 — the built-in is back in scope
Go keywords such as map or range cannot be used as names at all; see
Syntax and call rules.
Built-in functions
These names are available without an import.
| Function | Purpose |
|---|---|
print(values...) | Write values to stdout, separated by spaces, ending with a newline |
eprint(values...) | Same as print, but write to stderr |
range(start, end) | Create integer values from start through end-exclusive |
min(values...) / max(values...) | Choose the smallest or largest numeric value |
Int(value) / Float(value) / Str(value) / Bool(value) | Convert a value |
Bytes(value) / List(iterable) / Dict(key=value, ...) | Construct a collection value |
Chan([size]) | Create a channel; no size means unbuffered |
Function(value) | Validate and return a callable value unchanged |
spawn(function, args...) | Run a function concurrently, fire-and-forget |
Goblin(function, args...) | Run a function concurrently, returning a joinable handle |
Error(message) | Create an error value |
print and eprint return nil. range needs both start and end; it
has no one-argument form. min and max require at least one numeric
argument. Constructors that use an
argument parser, including range, numeric conversions, and Dict, accept
their documented keyword names; print, eprint, and spawn are
positional-only.
Use eprint for diagnostics and warnings so they stay off the program's
normal stdout stream (the same role as Python's
print(..., file=sys.stderr), Rust's eprintln!, or Go's
fmt.Fprintln(os.Stderr, ...)).
var limits = [3, 8, 5]
print(min(*limits), max(*limits))
print(range(start=2, end=5))
print(Dict(host="127.0.0.1", port=8080))
eprint("ready on port", 8080)
See Built-in types for value-specific methods, and Concurrency for channels and spawn.
Constructors and type identity
Runtime values expose their constructor through value.constructor when the
value has a constructible type. The constructor is the same callable that is
used to create or convert that type:
var values = [1, "text", Bytes("raw"), [1], {"x": 1}, Chan(1)]
for value in values {
print(value.constructor)
}
func identity(value) {
return value
}
print(Function(identity) == identity) # true
print(identity.constructor == Function) # true
Goblin-defined instances and native standard-library values follow the same
rule: instance.constructor is identical to the type callable that created
them. nil has no constructor because it represents the absence of a value.
Built-in types
Goblin values have runtime types; variables do not need type annotations. An operation succeeds only when its value types are compatible.
| Type | Examples | Notes |
|---|---|---|
| Integer | 0, -42 | Signed whole number |
| Float | 3.14, -0.5 | Floating-point number |
| Bool | true, false | Logical value |
| Nil | nil | Absence of a value; it prints as nil |
| String | "hello" | Immutable Unicode text |
| Bytes | Bytes("data") | Immutable raw byte sequence |
| List | [1, "two"] | Ordered, mutable collection |
| Dict | {"name": "Ada"} | Mutable key/value collection |
| Chan | Chan(0) | Channel for concurrent functions |
| Goblin | Goblin(func() {}) | Handle to a function running concurrently |
| Function | func() {} | Callable value |
Custom types are covered in Types and methods.
Numbers
Integer literals have no decimal point; float literals do. Arithmetic preserves
an integer result when both operands are integers. If either operand is a
float, the result is a float. Integer division truncates its fractional part.
The % operator returns the remainder after truncating division; the result
has the dividend's sign and is a float if either operand is a float.
print(7 / 2) # 3
print(7 / 2.0) # 3.5
print(2 + 0.5) # 2.5
print(-3 * 4) # -12
print(-7 % 3) # -1
print(7.5 % 2) # 1.5
Numbers can be compared across integer and float values. Division or modulo by zero raises ZeroDivisionError. Int() and Float() convert numbers, booleans, and numeric strings; converting a float to Int removes its fractional portion.
print(Int("42")) # 42
print(Int(3.9)) # 3
print(Float(true)) # 1
print(max(3, 5, 4))
When to convert and when to calculate
Use Int() or Float() at the edge of a program, where a value arrives as text or where an integer operation must become floating-point. Keep calculations in their natural numeric form after that. For example, parse a configuration value once, then use min() and max() to keep it within a permitted range.
var requested_workers = Int("12")
var workers = min(max(requested_workers, 1), 8)
print(workers) # 8
Int() rejects non-numeric text with ValueError. This makes it suitable for validating numeric input inside a try/catch block.
Booleans and nil
Use Bool(value) to convert any value by truthiness. False, nil, numeric zero, and empty strings, lists, dictionaries, or bytes are false.
print(Bool("")) # false
print(Bool([1])) # true
print(!nil) # true
print(true && false) # false
A function with no explicit result returns nil. Comparing any value against
nil with == is safe — it is true only for nil itself — but arithmetic and
indexing on nil are errors.
Using truthiness for optional values
Truthiness is convenient for choosing a fallback or guarding an optional collection. Use an explicit comparison with nil when zero, false, or an empty collection must still be treated as a present value.
var nickname = ""
print(nickname || "anonymous") # anonymous
var limit = 0
if limit == nil {
print("no limit supplied")
}
Strings and bytes
Strings are immutable Unicode text. They can be iterated by character,
combined with +, and repeated with *, but they are not indexable with [].
Use index() or last_index() to find a character position. See
Strings for conversions and typical methods.
Bytes are immutable raw byte sequences. Bytes("ABC") has size 3 and its first element is the integer 65. Common byte methods mirror string operations: decode(), contains(), has_prefix(), split(), replace(), and trim(). Use Bytes for raw data and strings for text.
Working with bytes
Use Bytes when reading or sending binary-oriented data, or when indexing must produce numeric byte values. Use decode() when that data should become text.
var header = Bytes("GET")
print(header[0]) # 71
print(header.contains("E"))
print(header.decode()) # GET
Lists and dictionaries
Lists and dictionaries are mutable collections. A list is ordered and uses integer indexes; a dictionary maps keys to values. Empty collections are false in conditions. See Collections for creation, indexing, and method guides.
Channels
Chan(capacity) creates a channel. Send with send(), receive with recv(), and close with close(). Capacity 0 creates an unbuffered channel. Use spawn() to start a concurrent function.
var done = Chan(0)
spawn(func() {
done.send("finished")
})
print(done.recv())
done.close()
Coordinating concurrent work
An unbuffered channel makes send() wait until another function calls recv(). This makes it useful for returning one result from spawned work. A buffered channel can accept up to its capacity before a receiver is ready.
var results = Chan(2)
spawn(func() { results.send(2 * 2) })
spawn(func() { results.send(3 * 3) })
print(results.recv() + results.recv())
results.close()
Close a channel only when no more values will be sent. Receiving from a closed, drained channel raises ValueError, rather than producing a special nil value.
Goblin handles
Goblin(function, args...) starts the function in a new goroutine and returns a handle. wait() joins it: the function's result is returned, an error it raised is re-raised, and the outcome is cached across repeated calls. done() reports completion without blocking, and wait(timeout = seconds) raises TimeoutError when the function outlives the timeout.
func square(value) {
return value * value
}
var worker = Goblin(square, 6)
print(worker.wait()) # 36
See Concurrency for how handles, channels, and spawn fit together.
Common operations
| Type | Common constructors and operations |
|---|---|
| Integer / Float | Int(value), Float(value), max(...), min(...) |
| Bool / Nil | Bool(value), !value, value && other, value || other |
| String | Str(value), size, contains(), split(), replace() |
| Bytes | Bytes(value), size, decode(), contains(), split() |
| List | List(value), size, first, last, push(), pop(), sort(), copy() |
| Dict | Dict(), get(), set_default(), keys(), items(), update() |
| Chan | Chan(size), send(value), recv(), close() |
| Goblin | Goblin(function, args...), wait(), done() |
| Function | Function(value), value(...) |
Use value.attributes() in the REPL to inspect all operations provided by a
runtime value. Constructible values also expose value.constructor; see
Built-in functions.
Control flow
Goblin uses braces for blocks and does not need semicolons.
An indented style makes nested blocks easy to scan, but indentation is not part of the grammar: braces determine the block.
Conditions
Use if, else if, and else to select a branch:
if score >= 90 {
print("excellent")
} else if score >= 60 {
print("passed")
} else {
print("try again")
}
Every condition is evaluated with the truthiness rules described in Built-in types. This lets you test optional values and collections directly:
var users = []
if users {
print("have users")
} else {
print("no users")
}
while loops
A while loop repeats while its condition is true. break leaves the nearest
loop and continue skips to its next iteration.
var n = 0
while n < 10 {
n = n + 1
if n == 3 {
continue
}
if n == 6 {
break
}
print(n)
}
Be sure that a while condition eventually changes or that the loop reaches a
break; Goblin does not impose a loop limit. In nested loops, break and
continue apply only to the innermost loop.
for ... in loops
for iterates over lists, strings, dictionaries, and other iterable values.
Iterating over a dictionary produces its keys; do not depend on their order.
for name in ["Ada", "Linus"] {
print(name)
}
for i in range(0, 3) {
print(i) # 0, 1, 2
}
range(start, end) creates integers from start up to, but excluding, end.
It also accepts named arguments, such as range(start=0, end=3).
Strings iterate by character, lists by element, and dictionaries by key:
var scores = {"Ada": 10, "Linus": 9}
for name in scores {
print(name, scores[name])
}
for character in "go" {
print(character)
}
Dictionary order is unspecified. If output order matters, do not build it by iterating a dictionary directly.
Functions
Define a function with func. Parameters do not have declared types, and
return sends a result back to the caller. A function without a value returns
nil.
func add(a, b) {
return a + b
}
print(add(2, 3))
Use a bare return to exit early. It returns nil.
func describe(value) {
if value == nil {
return
}
print(value)
}
Functions are values: assign them, pass them to another function, or return them. An anonymous function omits the name.
var square = func(x) { return x * x }
func apply(f, value) {
return f(value)
}
print(apply(square, 5))
An anonymous function captures variables in its surrounding scope, so it can form a closure:
func multiplier(n) {
return func(x) { return x * n }
}
var double = multiplier(2)
print(double(21))
The captured value belongs to the closure's surrounding scope. Each call to
multiplier above creates a separate function value, so multiplier(3) would
produce a different closure.
Parameters
Calls may use positional arguments or parameter names:
print(add(a=2, b=3))
Positional arguments must precede named arguments. A parameter can receive a value only once; passing the same parameter positionally and by name is an error.
A parameter can declare a default value with =, making it optional at the
call site:
func greet(name, msg = "hi") {
print(msg, name)
}
greet("bob") # hi bob
greet("bob", msg = "yo") # yo bob
A required parameter cannot follow one with a default. The default expression
is evaluated in the function's defining scope — it cannot reference other
parameters — and it is evaluated on each call that omits the argument, so a
mutable default like [] produces a fresh value every time.
A * parameter collects extra positional arguments, while a ** parameter
collects extra named arguments. They receive a list and a dictionary,
respectively.
func show(first, *rest, **options) {
print(first)
print(rest)
print(options)
}
show("a", "b", "c", color="green")
The *rest parameter must be last, unless a final **options follows it.
**options must always be last. Expand a list or dictionary at a call site
with f(*items) or f(**options):
func add3(a, b, c) {
return a + b + c
}
var values = [1, 2, 3]
print(add3(*values))
A **-expanded value must be a dictionary with string keys, and each keyword
name may only be supplied once per call: naming an argument twice — whether by
two explicit keywords, two ** dictionaries that share a key, or one of each —
raises a TypeError.
Recursion and callbacks
A named function can call itself. Functions also work naturally as callbacks because they are ordinary values.
func factorial(n) {
if n <= 1 {
return 1
}
return n * factorial(n - 1)
}
func map_values(values, transform) {
var result = []
for value in values {
result.push(transform(value))
}
return result
}
print(factorial(5))
print(map_values([1, 2, 3], func(x) { return x * x }))
Strings
Strings are immutable Unicode text values written with double quotes. Escape a double quote or a backslash with a backslash.
var message = "say: \"hello\""
print(message) # say: "hello"
print(message.size) # 12
size counts Unicode characters, not bytes; it is a property, not a method. Strings iterate by character, but
they cannot be indexed with []; use index() or last_index() when a character
position is needed.
var language = "Goblin"
for character in language {
print(character)
}
Combining and converting text
Use + to concatenate strings. A string can also concatenate an integer or boolean, and * repeats it by an integer count, in either operand order.
print("go" + "blin") # goblin
print("item-" + 3) # item-3
print("ha" * 3) # hahaha
print(3 * "ha") # hahaha
Str(value) converts any value to its display text. This is useful when building a string from a float, list, custom type, or nil.
var label = "port=" + Str(8080)
print(label)
Common string methods
| Method | Purpose |
|---|---|
| size | Character count (property) |
| upper() / lower() / title() | Change letter case |
| contains(substring) | Test for a substring |
| has_prefix(prefix) / has_suffix(suffix) | Test the beginning or end |
| index(substring) / last_index(substring) | Find a substring; returns -1 if absent |
| count(substring) | Count non-overlapping occurrences |
| replace(old, new, count=-1) | Replace all occurrences by default |
| split(separator, count=-1) | Split into a list |
| split_after(separator, count=-1) | Split while retaining separators |
| trim(cutset=nil) | Trim Unicode whitespace or supplied characters |
| trim_prefix(prefix) / trim_suffix(suffix) | Remove one matching edge |
| cut(separator) | Split once, returning a three-element result |
| repeat(count) | Repeat text |
Methods accept named arguments as well as positional ones.
var title = " Goblin book "
print(title.trim().upper())
print("a,b,c".split(sep=",", count=2))
print("one one one".replace(old="one", new="two", count=2))
print("config.toml".trim_suffix(".toml"))
trim() with no argument removes Unicode whitespace. Supplying a string trims any of its characters from both ends; it does not remove an exact substring. Use trim_prefix() or trim_suffix() when that distinction matters.
Common text-processing patterns
Use trim() before validating user-supplied text, split() to turn a delimited setting into a list, and replace() when normalizing a known spelling or format.
var raw_tags = " go, language,tools "
var tags = []
for tag in raw_tags.split(",") {
tags.push(tag.trim())
}
print(tags)
var filename = "report.TXT"
if filename.lower().has_suffix(".txt") {
print("text file")
}
Use contains() when a yes/no answer is enough. Use index() or last_index() when you need the position, such as separating a filename from its final extension.
Collections
Lists and dictionaries are mutable collections. Their elements can have mixed runtime types. Empty collections are false in conditions.
Lists
Create a list with square brackets. Direct list indexes begin at zero and must be non-negative.
var tasks = ["write", "test"]
print(tasks[0])
tasks[1] = "review"
tasks.push("ship")
print(tasks.pop())
An index outside the list raises IndexError. The + operator makes a new concatenated list; push(), reverse(), sort(), and clear() change the current list.
var first = [1, 2]
var combined = first + [3, 4]
print(combined) # [1, 2, 3, 4]
List method guide
| Method | Purpose |
|---|---|
| size | Number of elements (property) |
| push(value, ...) | Append one or more values |
| pop(index=-1) | Remove and return an element; defaults to the last |
| first / last | Read the first or last element without removing it (properties) |
| insert(index, value) | Insert before an index |
| remove(value) | Remove the first matching value |
| contains(value) / count(value) | Test for or count a value |
| index(value, start=0) | Find a value from an offset |
| join(separator) | Convert elements to text with a separator |
| reverse() / sort() | Reorder the list in place |
| copy() / clear() | Duplicate or empty the list |
pop(), first, and last raise IndexError when the requested element is
unavailable. pop() accepts a negative index, so its default -1 removes the
last element. index() returns -1 when a value is absent, and remove() returns
true when it removed a value or false when it did not.
Common list patterns
Use push() while building a result, pop() when processing a work stack, and copy() before an operation that should not change the original list.
var names = ["Ada", "Linus", "Grace"]
var greetings = []
for name in names {
greetings.push("Hello, " + name)
}
print(greetings.join(" | "))
var original = [3, 1, 2]
var ordered = original.copy()
ordered.sort()
print(original) # [3, 1, 2]
print(ordered) # [1, 2, 3]
Use contains() for membership tests. Use index() when the position matters; check for a missing value before using its result as an index.
Transforming lists with callbacks
Lists also provide callback-based helpers. map() returns transformed values, filter() keeps values whose callback is truthy, and reduce() combines values from left to right. each() runs a callback for its side effect; find() returns the first matching value or nil.
var values = [1, 2, 3, 4]
var squares = values.map(func(value) { return value * value })
var large = values.filter(func(value) { return value > 2 })
var total = values.reduce(func(acc, value) { return acc + value }, 0)
print(squares) # [1, 4, 9, 16]
print(large) # [3, 4]
print(total) # 10
Callbacks receive one list element, except reduce(), whose callback receives the accumulator followed by the element. Without an initial value, reduce() uses the first list element and raises TypeError for an empty list. any() and all() accept an optional callback; with none, they test each element's truthiness. sum() adds all elements.
Dictionaries
Create a dictionary with key: value pairs. Read and write through a key.
var user = {"name": "Ada", "age": 36}
print(user["name"])
user["active"] = true
print(user.get("role", default="reader"))
Keys may be strings, integers, floats, booleans, or nil. Key identity
follows ==: keys of different types stay distinct (d[1] and d["1"]
are separate entries), while equal numbers name the same entry (d[1]
and d[1.0] are one key). Using a mutable value (a list, dictionary, or
custom instance) as a key raises TypeError.
Looking up a missing key with dictionary[key] raises KeyError. Use get() when a missing value is expected. Dictionary iteration yields keys; items() yields two-element [key, value] lists. Dictionary iteration order is unspecified.
var settings = {}
settings.set_default("theme", "dark")
for pair in settings.items() {
print(pair[0], pair[1])
}
Dictionary method guide
| Method | Purpose |
|---|---|
| size | Number of entries (property) |
| contains(key) | Test whether a key exists |
| get(key, default=nil) | Read a key without raising for a missing key |
| set_default(key, default=nil) | Get an existing value or insert a default |
| keys() / values() / items() | Return lists of keys, values, or key/value pairs |
| pop(key) | Remove and return a value |
| update(other) | Copy entries from another dictionary |
| copy() / clear() | Duplicate or empty the dictionary |
List() and Dict() construct the corresponding collections. See Control flow for iteration.
Common dictionary patterns
Use get() to read optional configuration, set_default() to initialize a value once, and update() to merge a set of overrides into a base dictionary.
var defaults = {"host": "127.0.0.1", "port": 8080}
var overrides = {"port": 3000}
defaults.update(overrides)
print(defaults["port"]) # 3000
var counts = {}
for word in ["go", "goblin", "go"] {
var current = counts.get(word, default=0)
counts[word] = current + 1
}
print(counts["go"]) # 2
Use keys(), values(), or items() only when a list snapshot is useful. For a simple dictionary traversal, iterate the dictionary itself and look up each value by its key.
Types and methods
type defines a custom type with fields and methods. The parentheses list
fields supplied at construction; a field may have a default value. Every method
must declare self as its first parameter. Type definitions belong at module
scope, not inside a function or a control-flow block.
type Point(x, y=0) {
func move(self, dx, dy) {
self.x = self.x + dx
self.y = self.y + dy
}
func text(self) {
return "(" + Str(self.x) + ", " + Str(self.y) + ")"
}
}
var p = Point(1)
p.move(2, 3)
print(p.text())
Instance fields can be read and updated directly. Construction accepts both positional and named arguments:
var origin = Point(x=0, y=0)
origin.x = 10
Required fields must come before fields with defaults. Calling a type requires all required fields exactly once; named arguments make construction clearer when a type has several fields.
Methods and state
Methods are ordinary functions attached to a type. They can read or replace
fields through self, and methods may call other methods on the same instance.
type Counter(value=0) {
func increment(self) {
self.value = self.value + 1
return self.value
}
}
var counter = Counter()
print(counter.increment()) # 1
print(counter.increment()) # 2
Protocol methods
Goblin lets custom types participate in operations and protocols through
conventionally named methods such as __add, __cmp, __bool, __str,
__iter, and __getitem. These names have leading double underscores only;
there are no trailing underscores. Most programs can start with ordinary fields
and methods.
The most useful protocol methods are shown below. Their parameter shapes are
fixed: use self alone for conversion and iteration methods, self, other for
binary operators and comparison, and self, index, value for __setitem.
| Method | Enables |
|---|---|
__add, __sub, __mul, __div, __mod | Arithmetic operators |
__radd, __rsub, __rmul, __rdiv, __rmod | The same operators with the instance on the right |
__not | Logical ! operator; without it ! negates truthiness |
__cmp | ==, !=, <, <=, >, >=; return -1, 0, or 1. Consulted from either side of a comparison |
__str | Printing and Str(value) |
__bool | Conditions, Bool(value), and the truthiness test in && / ` |
__iter | for value in instance |
__getitem, __setitem | instance[index] read and assignment |
If a protocol method is absent, the corresponding operation raises TypeError.
Equality is the exception: without __cmp, == and != fall back to
identity, so an instance is equal only to itself and never raises. Ordering
comparisons (<, <=, >, >=) still require __cmp.
With __cmp, a type mismatch inside it still reads as "unequal" — money == nil stays false for a __cmp written only for numbers — but any other failure
is reported rather than swallowed, so == raises whatever the method raised.
A comparison consults both operands, so the instance does not have to be on the left. When the left operand has no ordering for the right one — as a built-in number never has for a custom type — the right operand is asked to compare itself against the left and the result is flipped:
type Money(amount) {
func __cmp(self, other) {
return self.amount - other
}
}
var m = Money(5)
print(m < 10) # true
print(10 > m) # true, answered by Money.__cmp
Arithmetic cannot flip its operands the same way — a - b is not b - a — so
it uses a second set of methods instead. When the left operand does not know
the right one, the right operand's __radd, __rsub, __rmul, __rdiv or
__rmod is called with the left operand as its argument:
type Scaled(factor) {
func __mul(self, other) {
return Scaled(self.factor * other)
}
func __rmul(self, other) {
return Scaled(other * self.factor)
}
}
var s = Scaled(3)
print(s * 2) # Scaled(6)
print(2 * s) # Scaled(6), answered by __rmul
A reflected method is only reached after the left operand reports that the types do not fit. Anything else it raises — a division by zero, a failure inside its own body — propagates instead, and an operator whose reflected method is not defined keeps reporting the error of its left operand.
The built-in sequences follow the same rule, so repetition reads in either
order: 3 * "ab" and 2 * [1, 2] work like "ab" * 3 and [1, 2] * 2.
For example, this type supports + and printing:
type Vector(x, y) {
func __add(self, other) {
return Vector(self.x + other.x, self.y + other.y)
}
func __str(self) {
return "Vector(" + Str(self.x) + ", " + Str(self.y) + ")"
}
}
print(Vector(1, 2) + Vector(3, 4))
Errors
Errors are values. Use raise to stop normal execution with an error and try / catch to recover at a boundary that can make a useful decision.
func divide(a, b) {
if b == 0 {
raise ZeroDivisionError.wrap("divide")
}
return a / b
}
try {
print(divide(10, 0))
} catch err {
print(err.message)
print(err.is(ZeroDivisionError))
}
The name after catch is local to its catch block. When no error is raised, the catch block is skipped. An unhandled error stops the program and prints a traceback.
Creating and matching errors
Error("message") creates a plain error. Use a sentinel error when callers need to distinguish a particular failure from unrelated errors.
var not_found = Error("not found")
func load_user(id) {
if id == 0 {
raise not_found.wrap("loading user")
}
return {"id": id}
}
try {
load_user(0)
} catch err {
if err.is(not_found) {
print("choose another user")
}
}
wrap("context") adds a message while preserving the original error. unwrap() returns the direct cause, and is() tests the complete error chain.
var base = Error("connection failed")
var err = base.wrap("loading profile")
print(err.message)
print(err.unwrap().message)
print(err.is(base))
Error kinds
The runtime uses named error kinds, which can be raised directly or wrapped.
| Kind | Typical cause |
|---|---|
| TypeError | An operation receives an incompatible value |
| ValueError | A valid type has an invalid value |
| IndexError / KeyError | A missing list index or dictionary key |
| ZeroDivisionError | Division or modulo by zero |
| AttributeError / NameError | A missing member or identifier |
| ImportError | An unavailable module |
| ParseError | Invalid JSON or other parsed input |
| IOError | Generic file, network, or operating-system failure |
| NotExistError / ExistError / PermissionError | A missing, existing, or inaccessible filesystem path |
| TimeoutError / NetworkError | A timed-out or other network operation |
| NotImplementedError | An operation the runtime does not implement |
Kinds are hierarchical. IndexError and KeyError are LookupErrors; ZeroDivisionError is an ArithmeticError; ParseError is a ValueError. Therefore err.is(LookupError) can handle more than one specific lookup failure.
Wrap errors where you can add useful context. Do not catch an error merely to discard it: either recover with a meaningful alternative, or use raise err to let an outer layer handle it.
Concurrency
There are two ways to start a function in a new goroutine. Goblin(function, args...) starts it immediately and returns a handle; wait() on the handle
joins the function and delivers its result. spawn(function, args...) is
fire-and-forget: it returns nil immediately, and the function's return value
and unhandled error are discarded. Use a Goblin handle when the caller cares
about the outcome, and spawn when it does not.
func square(value) {
return value * value
}
var worker = Goblin(square, 6)
print(worker.wait()) # 36
Goblin handles
Construction starts the function right away; there is no separate start step. Arguments after the function are forwarded to it.
| Operation | Behavior |
|---|---|
Goblin(function, args...) | Start the function in a new goroutine and return a handle |
handle.wait() | Block until the function finishes, then return its result |
handle.wait(timeout = seconds) | Same, but raise TimeoutError if the function is still running when the timeout expires |
handle.done() | Report whether the function has finished, without blocking |
The outcome is computed once and cached, so wait() can be called any number
of times — and from several places at once — with the same answer. An error
raised inside the function is not printed anywhere; it is stored and re-raised
by every wait() call, where the normal try/catch machinery applies:
func fail() {
raise ValueError.wrap("bad input")
}
var failing = Goblin(fail)
try {
failing.wait()
} catch err {
print(err.is(ValueError)) # true
}
A handle that is never waited on is abandoned: when the program ends, running
goblins are stopped mid-flight, and a stored error nobody asked for is
discarded silently. If a function's failure matters, wait() for it; if the
result never matters, spawn is the honest way to say so (an uncaught error
in a spawned function is at least reported on stderr).
Sharing data with a goblin
Pass data into a goblin through its arguments, and out through its return value or a channel. Two goblins — or a goblin and the top-level program — reading and writing the same variable concurrently is a data race: no ordering is defined, and the compiled backend inherits Go's undefined behavior for races. Reading surrounding names that nothing writes concurrently (module imports, top-level functions, builtins) is safe.
The same rule covers the inside of a shared mutable value: lists and
dictionaries are not synchronized, and Goblin never locks them for you. The
consequence of a race is not limited to stale or torn data — two goblins
writing to the same dictionary can terminate the whole process at once, and
try/catch cannot intercept that crash. When several goblins must work on
the same data, keep one owner that mutates it and let the others send requests
or results over a channel, as in the examples below.
goblin build-exe --race compiles the executable with Go's race detector:
when a race actually occurs at run time, the program reports both racing
operations with stack traces and exits with a failure status. The interpreter
has no equivalent switch — race instrumentation happens when Go code is
compiled — so a program that only ever ran under goblin run may hide a race
that the compiled backend hits. Build the racy suspect with --race and
exercise it when in doubt.
Channels
Chan() and Chan(0) create an unbuffered channel. A send waits until some
goroutine receives it, and a receive waits until a value is sent. Chan(size)
creates a buffer that can hold up to size values before sends block.
| Operation | Behavior |
|---|---|
channel.send(value) | Blocks until a receiver is ready or buffer space exists |
channel.recv() | Blocks until a value is available |
channel.close() | Prevents future sends; buffered values can still be received |
Chan(size) | Requires a non-negative integer; omitting size means zero |
Sending on a closed channel, closing a channel twice, or receiving after a channel is closed and drained raises ValueError. Channels are not iterable and there is no special end-of-stream value, so a receiver must know how many values to expect or use a separate completion signal.
Buffering a known number of results
Use a buffered channel when several workers can finish before the caller starts receiving. The buffer capacity here matches the number of results.
func square(value, result) {
result.send(value * value)
}
var results = Chan(3)
for value in [2, 3, 4] {
spawn(square, value, results)
}
var total = 0
for ignored in range(0, 3) {
total = total + results.recv()
}
results.close()
print(total) # 29
An unbuffered results channel also works in this example because the caller
begins receiving after it starts the workers. Buffering changes when sends
block; it does not guarantee a result order. Do not rely on spawned work
finishing in the order it was started.
Returning errors explicitly
wait() re-raises a goblin's error at the call site, which covers the common
case of one job whose failure the caller handles. When workers stream results
through a channel instead, an error raised inside a spawned function is not
delivered to the caller. Catch it in the worker and send a result record when
the caller needs to handle failures.
func load_number(text, result) {
try {
result.send({"value": Int(text), "error": nil})
} catch err {
result.send({"value": nil, "error": err})
}
}
var result = Chan()
spawn(load_number, "not-a-number", result)
var outcome = result.recv()
if outcome["error"] {
print(outcome["error"].message)
}
result.close()
Use a dictionary only as a small result record like this. For a repeated or larger protocol, define a custom type so the fields and methods are explicit.
Ownership and deadlocks
The code that knows no more values will be sent should close the channel. Do not close a channel while a spawned sender may still use it. A common deadlock is sending to an unbuffered channel in the same goroutine before starting a receiver:
var messages = Chan()
# messages.send("hello") would block here: no receiver can run yet.
spawn(func() { messages.send("hello") })
print(messages.recv())
messages.close()
Goblin has no select operation, no cancellation primitive, and no timeout on
channel operations; wait(timeout = seconds) on a Goblin handle is the only
time-bounded wait, and functions started with spawn cannot be joined at all.
Design each concurrent operation so every blocking send has a receiver, every
expected result is received, and the owner can decide when it is safe to close
its channel.
Troubleshooting
When a program fails, first identify which stage reported the problem. The message and traceback include the source location.
| Symptom | Likely cause | What to check |
|---|---|---|
| Parse error | Syntax the grammar does not accept | Braces, commas, string escapes, and function parameter syntax |
| Semantic error | A name, declaration, or statement is invalid in its scope | Declare names before use; keep import/type/export at module scope |
| NameError / AttributeError | A runtime name or member is unavailable | Spelling, module import, and value.attributes() in the REPL |
| TypeError | An operation received an unsupported value type | Constructor inputs, callback return values, and operator operands |
| IndexError / KeyError | A list index or dictionary key is absent | Bounds checks, dict.get(), and list.index() returning -1 |
| IOError / NetworkError | A filesystem or HTTP operation failed | Paths, permissions, connectivity, and a try/catch recovery boundary |
A reliable debugging loop
Reduce the failing code to a small .goblin file, then run it through the
interpreter:
goblin run failing.goblin
Use goblin repl for inspecting values. value.attributes() lists the
operations an object exposes, which is particularly useful for module values,
responses, files, and custom types.
If build-exe fails after a program works with run, verify that the Go
toolchain is installed and run the command again with the generated-build
message visible. Report the Goblin source, command, full error text, and
whether the interpreter path succeeds when filing an issue.
build-exe compiles against the same published Goblin runtime version as the
installed CLI unless it finds a local source checkout (from the working
directory or the executable's location). Development builds without embedded
module-version information use a pinned runtime fallback. When developing
Goblin itself, set GOBLIN_ROOT to the checkout path so compiled programs use
your local runtime.
Standard library
Goblin's standard library provides modules for common program boundaries: files, environment variables, external commands, paths, time, data formats, networking, random values, mathematics, and MIME metadata. Import a module at module scope, then access its members with dot notation.
import "json"
import "path"
var cwd = path.cwd()
var text = json.marshal({"cwd": cwd})
print(text)
The standard library is separate from Goblin's built-in functions and types. For example, print(), eprint(), range(), Int(), List(), Dict(), and Chan() are available without an import. Import a module only when its capabilities are needed.
Two tiers: core and x/
The standard library has two tiers. Core modules have curated, Goblin-shaped
APIs and simple names such as "json" and "fs". Modules under the x/ prefix
are direct adaptations of Go packages and keep Go's package hierarchy in their
import path, so compress/gzip becomes x/compress/gzip. In both tiers the
imported name is the last path component:
import "x/compress/gzip"
var packed = gzip.compress("hello")
Core modules
| Module | Main purpose | Start with |
|---|---|---|
| json | Encode and decode JSON | marshal(), unmarshal() |
| fs | Read, write, inspect, and remove files | read(), write(), exists() |
| os | Read environment and process information | argv(), getenv(), getwd(), hostname() |
| exec | Configure and execute external commands | Command() |
| path | Find the current or home directory | cwd(), home() |
| time | Work with time and durations | now(), sleep(), parse() |
| rand | Generate reproducible random values and permutations | Rand(), int(), shuffle() |
| math | Numeric constants and functions | pi, sqrt(), pow(), abs() |
| http | Make HTTP requests | get(), post(), put() |
| uuid | Construct, generate, and validate UUID values | UUID(), new(), is_valid() |
| regexp | Search, capture, replace, and split text with RE2 expressions | compile(), escape() |
| url | Parse, resolve, join, and escape URLs | parse(), query_escape() |
| csv | Read and write comma-separated records | read_all(), write_all() |
x/ modules
| Module | Main purpose | Start with |
|---|---|---|
| x/encoding/base64 | Encode and decode Base64 text | encode(), decode() |
| x/encoding/base32, ascii85, html, quotedprintable | Encode text and escape HTML | encode(), escape() |
| x/encoding/hex | Encode, decode, and dump hexadecimal data | encode(), decode() |
| x/encoding/pem | Encode and decode PEM blocks | Block(), decode() |
| x/mime | Look up MIME types and extensions | type_by_extension() |
| x/crypto/sha256 and sha512 | Compute fixed-size SHA-2 digests | sum(), hex() |
| x/crypto/md5, sha1, x/hash/crc32, adler32 | Compute compatibility digests and checksums | hex(), checksum() |
| x/crypto/hmac, x/hash/crc64, fnv | Compute keyed and non-cryptographic hashes | sum(), hex() |
| x/compress/gzip, zlib, flate, bzip2 | Compress and decompress complete byte values | compress(), decompress() |
| x/compress/lzw | Compress and decompress LZW data | compress(), decompress() |
| x/archive/tar and zip | Read and write complete in-memory archives | read_all(), write_all() |
| x/net/mail | Construct and parse email addresses | parse_address() |
| x/net/netip | Parse and calculate with IP addresses and prefixes | Addr(), Prefix() |
| x/unicode and x/unicode/utf8 | Validate UTF-8 and classify Unicode characters | valid(), is_letter() |
Imports and errors
Standard-library module names never start with "./" or "../"; core names are plain ("json", "fs") and x/ names carry their Go-style path ("x/compress/gzip"). Local source modules use a relative import such as "./modules/greeter"; those are documented in Modules and imports because they use the same import syntax.
Most standard-library operations that touch the outside world can fail. JSON parsing may raise ParseError, a missing file may raise an I/O-related error, and HTTP requests may fail. Use try/catch around work that your program can recover from; see Errors.
Choosing a module
Use json whenever a program boundary expects JSON rather than trying to build JSON text manually. Prefer fs for simple whole-file reads and writes, and use its open() function when a file object is needed. Use path.cwd() or path.home() instead of assuming a current directory. Use time.sleep() only for intentional delays, and use Chan plus spawn() for communication between concurrent Goblin functions.
Each module has its own chapter in this section, with a focused API reference and example.
Reading API signatures
Examples and tables use name(required, optional=value) to show argument
order and defaults. Square brackets mean an argument may be omitted, as in
Chan([size]). They do not promise that named arguments are accepted: a
function's chapter calls out positional-only APIs where that matters.
Unless a chapter says otherwise, a function that touches files, the operating system, or the network can raise an error value. Wrap the smallest useful boundary in try/catch, then add context or recover deliberately.
Modules and imports
A module groups related values. Import statements belong at module scope. An imported name is the final component of its path.
import "json"
import "./modules/greeter"
print(json.marshal({"ok": true}))
greeter.greet("world")
Local modules
Local paths must start with ./ or ../ and are resolved relative to the file that imports them; any other path, slashes included, names a standard-library module (like "x/compress/gzip"). Omit the .goblin suffix. A local module chooses its public names with export.
# modules/greeter.goblin
var greeting = "Hello"
func greet(name) {
print(greeting, name)
}
export greet
Names without export remain private. Define a module-level name before code that uses it; this also applies to names referenced from an exported function. Named functions may call themselves recursively.
A complete local module
For a small project, keep the entry point and local module in separate files:
project/
├── main.goblin
└── modules/
└── greeter.goblin
# modules/greeter.goblin
var greeting = "Hello"
func greet(name) {
return greeting + ", " + name
}
export greet
# main.goblin
import "./modules/greeter"
print(greeter.greet("world"))
Run the entry point from project/ with goblin run main.goblin. Local import
paths are resolved from the importing file, not from the shell's current
directory. A local import's final path component becomes its module name, so
./modules/greeter is available as greeter.
A module is loaded once and cached; later imports of the same file reuse the
loaded module. Two modules importing each other is a cycle, reported as
circular import detected.
The remaining chapters document each built-in module separately.
json
Import json to exchange data with JSON APIs and files. marshal(value, indent=0) produces JSON text; unmarshal(data) parses JSON text (str or Bytes) into Goblin values.
import "json"
var text = json.marshal({"name": "Ada", "scores": [90, 95]})
var user = json.unmarshal(text)
print(user["name"])
print(user["scores"][0])
Objects become dictionaries, arrays become lists, JSON numbers become Int or Float, and JSON null becomes nil. Pass a positive indent to marshal for readable output.
print(json.marshal({"ok": true}, 2))
unmarshal raises ParseError for invalid JSON. Catch it when parsing external input.
Values and formatting
marshal accepts every standard Goblin value that has a JSON equivalent: dictionaries, lists, strings, integers, floats, booleans, and nil. Dictionary keys must be strings and are encoded as JSON object keys; a non-string key raises TypeError. The optional indent argument controls pretty printing; omit it for compact data sent over a network.
var payload = {
"name": "Ada",
"tags": ["math", "logic"],
"enabled": true,
"note": nil
}
print(json.marshal(payload, 2))
When decoding, inspect the resulting values with ordinary list and dictionary operations. A JSON integer becomes Int while a decimal number becomes Float.
Handling untrusted input
JSON from a file or HTTP response is external input. Keep parsing and validation separate: first catch malformed JSON, then verify the fields your program requires.
try {
var config = json.unmarshal("{\"port\": 8080}")
var port = config.get("port", default=8080)
print(port)
} catch err {
if err.is(ParseError) {
print("configuration is not valid JSON")
} else {
raise err
}
}
fs
The fs module reads, writes, and inspects files and directories. Most functions accept a string or Path as their path argument.
import "fs"
fs.write("notes.txt", "remember this")
print(fs.read("notes.txt"))
print(fs.exists("notes.txt"))
fs.append("notes.txt", "\nnext line")
fs.remove("notes.txt")
Use read() and write() for whole-file work. write() replaces a file, while append() adds text and returns the number of bytes written.
| Function | Purpose |
|---|---|
| open(path) / create(path) | Open an existing file or create one |
| read(path) / write(path, text) / append(path, text) | Whole-file text I/O |
| exists(path) | Check whether a path exists |
| stat(path) | Return name, size, and directory information |
| read_dir(path) | Return file-information entries |
| mkdir(path) / remove(path) | Create a directory or remove a path |
Files returned by open() or create() should be closed after use. Filesystem operations can raise IOError.
File objects
open(path) returns a read-oriented file object and create(path) returns a file that can be written. File objects expose name, closed, read(size), write(content), stat(), and close(). read() follows the same Reader protocol as an HTTP response body: with no argument it consumes all remaining data, read(size) returns a chunk of up to size bytes, end of file is an empty Bytes, and the result is always Bytes — call .decode() for text. write() accepts str or Bytes and returns the number of bytes written.
Because a File has a write(data) method, it already satisfies the writer
stream shape: it can be passed wherever the standard library accepts a writer
object, such as exec.Command(stdout=...) or the dest= keyword of
csv.write_all, tar.write_all, and gzip.compress.
var file = fs.create("log.txt")
file.write("started\n")
print(file.name)
file.close()
var reader = fs.open("log.txt")
print(reader.read().decode())
print(reader.stat().size)
reader.close()
fs.remove("log.txt")
Always close a file once its work is finished, including after a try/catch block. For one small text file, fs.read() and fs.write() are simpler and avoid managing a file object.
Inspecting directories
stat(path) and read_dir(path) return FileInfo values. Their common fields are name, size, is_dir, mode, and mod_time.
var entries = fs.read_dir(".")
for entry in entries {
if entry.is_dir {
print("directory:", entry.name)
} else {
print("file:", entry.name, entry.size)
}
}
mkdir() creates one directory only; it fails when the parent is missing or the path already exists. remove() removes one file or empty directory.
os
The os module provides process and environment information. Use it for configuration supplied by the operating system rather than hard-coding secrets or deployment settings.
import "os"
var port = os.getenv("PORT")
if port == "" {
port = "8080"
}
print(port)
print(os.getwd())
print(os.hostname())
# argv() is the program command line (index 0 is the invocation name)
for arg in os.argv() {
print(arg)
}
| Function | Purpose |
|---|---|
| argv() | Return the program command-line arguments as a list of strings |
| getenv(key, default=nil) / setenv(key, value) / unsetenv(key) | Read or change environment values |
| environ() | Return all environment values as a dictionary |
| getwd() / hostname() | Current directory and machine name |
| getpid() / getppid() | Process identifiers |
| tempdir([dir, pattern]) / tempfile([dir, pattern]) | Create temporary paths |
| exit(code=0) | End the process |
Avoid using exit() inside reusable library code. Environment and temporary-file operations can raise IOError. argv() does not accept arguments and returns a fresh list each call; mutating that list does not change the program arguments.
Command-line arguments
argv() presents the command line from the Goblin program's point of view. Index
0 is its invocation name; remaining elements are the arguments passed after it.
With goblin run, the source path is the invocation name — for example goblin run app.goblin foo bar makes argv() return ["app.goblin", "foo", "bar"].
Arguments that look like flags (such as -v or --help) are forwarded only
when they appear after the source file. Put the source file first. Leading
flags are rejected.
For CLI help use goblin run -h or goblin help run (alone, with no source
file). Compiled executables from build-exe see the real process argv (the
binary path at index 0). In the REPL, argv() is [""] so interactive
sessions do not expose the goblin process arguments. Use argv() when a program
needs flags or positional inputs from the shell; prefer getenv() for
configuration that should not be visible on the command line.
var args = os.argv()
if args.size < 2 {
print("usage:", args[0], "<file>")
os.exit(1)
}
print("input:", args[1])
Configuration from the environment
getenv() returns nil for a missing key; pass default= to supply a fallback
value (for example os.getenv("PORT", default="8080")), or use environ() when
a program needs to inspect the complete environment dictionary.
var env = os.environ()
var mode = env.get("APP_MODE", default="development")
var debug = mode == "development"
print(debug)
setenv() changes only the environment of the current Goblin process and processes it starts. It does not persist after the program exits.
Temporary paths and process identity
tempdir() and tempfile() accept optional directory and pattern arguments in that positional order, and return created paths. They are helpful for generated output and tests. These functions do not accept named arguments.
var directory = os.tempdir("", "goblin-")
var filename = os.tempfile(directory, "data-")
print(directory)
print(filename)
getpid(), getppid(), getuid(), geteuid(), getgid(), getegid(), and getgroups() expose identity information supplied by the host operating system; getpagesize() reports the memory page size. Availability and exact values can vary by platform.
exec
Import exec to configure and execute external commands. Commands are invoked
directly: arguments are never parsed by a shell.
import "exec"
var cmd = exec.Command(
"git",
["status", "--short"],
stdout=exec.CAPTURE,
stderr=exec.CAPTURE
)
var result = cmd.run()
if result.success {
print(result.stdout.decode())
}
Command
Command(name, args=[], cwd=unit, env=unit,
stdin=INHERIT, stdout=INHERIT, stderr=INHERIT)
name and every element of args must be strings. cwd accepts unit, a
string, or a Path. An omitted env inherits the current process environment;
a dictionary replaces it completely, so env={} starts the command with an
empty environment. Environment keys and values must be strings.
The standard-stream policies are:
| Policy | Meaning |
|---|---|
INHERIT | Use the corresponding Goblin process stream |
DISCARD | Provide EOF for stdin or discard output |
CAPTURE | Capture stdout or stderr into the result |
stdin also accepts Str or Bytes. CAPTURE is valid only for stdout and
stderr. Captured values are Bytes, because command output is not necessarily
UTF-8; call decode() when text is expected.
Besides the policies, stdout and stderr accept any writer object — an
object with a write(data) method, such as an open fs file. The command's
output streams into it as Bytes chunks, so large output never has to be
captured in memory:
import "exec"
import "fs"
var log = fs.create("build.log")
exec.Command("make", stdout=log, stderr=log).run()
log.close()
exec never calls the writer's close(); close the target yourself when the
command finishes. With start(), chunks may arrive while your program is doing
other work, so do not write to the same object from Goblin code until wait()
returns.
Executing a command
cmd.run() starts, waits for, and reaps a command. Output behavior comes only
from the stream configuration on Command.
var result = exec.Command(
"gofmt",
stdin="package main\nfunc main(){}",
stdout=exec.CAPTURE,
stderr=exec.CAPTURE
).run()
For explicit asynchronous control, use start() followed by wait():
var cmd = exec.Command("worker", ["--once"])
cmd.start()
print(cmd.pid)
var result = cmd.wait()
A command can be started only once. wait() before start() and a second
execution attempt raise ValueError. Repeated calls to wait() return the
same cached result. kill() terminates a started command; call wait()
afterward to obtain its result. cmd.pid is unit before startup, and
cmd.running() reports whether the command has not yet been reaped.
Result
| Attribute | Type | Meaning |
|---|---|---|
code | Int | Exit code; a signal may produce -1 |
success | Bool | Whether the exit code is zero |
stdout | Bytes or unit | Captured stdout, if configured |
stderr | Bytes or unit | Captured stderr, if configured |
A non-zero exit code is a normal result, not an exception. Failures to start
or wait for the command raise an I/O-related error. Inspect code or success
and implement any command-specific failure policy in Goblin code.
Shell commands
exec does not interpret pipes, redirects, glob patterns, or shell variables.
Pass every argument as a separate list element. If shell syntax is explicitly
required, invoke a platform shell yourself, for example exec.Command("sh", ["-c", script]); do not insert untrusted text into such a script.
path
The path module provides a Path value and directory factories. Use Path rather than manually joining strings when code must work with filesystem paths.
import "path"
import "fs"
var config = path.home().join("myapp", "config.json")
print(config)
print(fs.exists(config))
path.cwd() returns the current working directory and path.home() returns the user home directory. path.Path(text) constructs a Path explicitly.
Path values expose operations such as join(), name, parent, suffix, exists(), is_dir(), and read_text() or write_text() when working directly with a path. Use fs when a program prefers functional whole-file operations; use Path when several operations are derived from one base location.
Building derived paths
join() makes a child path without manually inserting separators. name, stem, suffix, parent, parts, and is_absolute describe a path without touching the filesystem.
var source = path.Path("reports/monthly.csv")
print(source.name) # monthly.csv
print(source.stem) # monthly
print(source.suffix) # .csv
print(source.parent)
with_name(name) and with_suffix(suffix) create adjusted paths. relative_to() and as_posix are useful when producing portable display strings.
Filesystem operations on Path
Path can directly test exists(), is_file(), is_dir(), and is_symlink(). It can read_text(), write_text(text), read_bytes(), write_bytes(bytes), mkdir(), unlink(), rename(target), iterdir(), and glob(pattern).
var output = path.cwd().join("output.txt")
output.write_text("generated")
if output.exists() {
print(output.read_text())
}
output.unlink()
These operations can raise IOError. Use fs.read_dir() for simple directory listing or Path.glob() when a pattern is the clearest expression.
time
The time module creates, parses, formats, and measures time values.
import "time"
var started = time.now()
time.sleep(0.1)
print(started.elapsed())
print(started.year)
print(started.format("2006-01-02"))
now() returns the current local time. sleep(seconds) pauses for an integer or float number of seconds. t.elapsed() returns the seconds elapsed since t as a Float.
Time(year, month, day, hour=0, minute=0, second=0, nanosecond=0) constructs a Time from calendar components in local time.
var launch = time.Time(2026, 7, 19, hour=9, minute=30)
print(launch.format("2006-01-02 15:04"))
Use parse(layout, text) to parse formatted text. Layouts use Go reference time formatting, so 2006-01-02 represents a year-month-day format. unix(seconds, nanoseconds=0) creates a time from a Unix timestamp.
var day = time.parse("2006-01-02", "2026-07-19")
print(day.weekday)
print(time.unix(day.unix))
Invalid parsing raises ParseError.
Time fields and formatting
Time values provide year, month, day, hour, minute, second, nanosecond, unix, unix_nano, and weekday fields. format(layout) turns a Time into text using the same Go reference-layout convention as parse().
var now = time.now()
print(now.year, now.month, now.day)
print(now.weekday)
print(now.format("2006-01-02 15:04:05"))
Time values can be compared with the ordinary comparison operators. Use this for expiration and scheduling checks; use elapsed() when the needed result is a duration in seconds.
var deadline = time.unix(2000000000)
if time.now() > deadline {
print("expired")
}
sleep() blocks the current execution path. It is appropriate for a deliberate delay or simple retry loop, but it is not a substitute for channel-based coordination between spawned functions.
rand
The rand module provides pseudo-random values backed by Go's math/rand
package. It is suitable for simulations, games, and reproducible tests, but not
for passwords, tokens, keys, or other security-sensitive work.
import "rand"
print(rand.int(10))
print(rand.float())
Module-level functions use a private, automatically seeded generator.
Module API
| Function | Description |
|---|---|
Rand(seed=...) | Create an independent generator; seed defaults to an automatic seed. |
int(max=nil) | Return a non-negative integer, optionally below max. |
float() | Return a Float in [0.0, 1.0). |
perm(n) | Return a random permutation of the integers [0, n). |
shuffle(list) | Rearrange a list in place and return unit. |
norm_float() | Draw from the standard normal distribution. |
exp_float() | Draw from the exponential distribution with rate 1. |
int() corresponds to Go's Int63. Passing max selects the bounded
Int63n behavior; merging those two Go functions is possible because Goblin
supports optional arguments. max must be positive.
shuffle accepts a Goblin list instead of Go's length-and-callback pair. The
callback exists only to let Go swap values of arbitrary static types; exposing
it would leak that implementation constraint into Goblin.
Independent generators
Rand(seed=...) wraps Go's rand.Rand. Two instances created with the same seed
produce the same sequence:
var first = rand.Rand(42)
var second = rand.Rand(42)
print(first.int(1000) == second.int(1000)) # true
A Rand provides the same int, float, perm, shuffle, norm_float, and
exp_float methods as the module. It is safe to share between spawned Goblin
functions. Concurrent scheduling still determines which caller receives each
successive value, so concurrent sequences are not reproducible.
Unlike the old random.Generator, construction always requires a seed. This
keeps independent generator creation conceptually aligned with Go's
rand.New(rand.NewSource(seed)); use the module functions when reproducibility
is not required.
Deliberately omitted Go API
Source,Source64, andNeware replaced byRand(seed=...). Go's source interfaces are extension points for Go implementations and should not leak into the Goblin value API.Readis omitted because its Go signature fills a caller-owned byte slice. A future byte-stream API should follow Goblin's common reader protocol rather than expose Go buffer mutation.- Integer-width variants (
Int31,Int63,Uint32, andUint64) are not separate functions because Goblin has one signed 64-bitInttype. Seedis omitted. Reproducible state belongs to an explicitRandrather than mutable module-global state.Zipfis omitted from the initial useful subset because it requires a specialized stateful distribution object.
The former random module and its Python-inspired choice, sample, bounded
float, parameterized normal, and parameterized exponential functions were
removed. They do not correspond to the Go package being wrapped.
math
The math module provides constants and floating-point functions. It is useful when the basic arithmetic operators are not enough.
import "math"
var radius = 3
var area = math.pi * math.pow(radius, 2)
print(area)
print(math.sqrt(81))
Common functions include abs(), ceil(), floor(), round(), trunc(), pow(), sqrt(), log(), exp(), sin(), cos(), tan(), min(), and max(). Constants include pi, e, inf, and nan.
The full trigonometric family is available: the inverses asin(), acos(), atan(), and two-argument atan2(y, x); the hyperbolic functions sinh(), cosh(), tanh() and their inverses asinh(), acosh(), atanh(). Logarithms come in three bases — natural log(), log2(), and log10() — alongside exp() and cbrt().
print(math.floor(3.8))
print(math.hypot(3, 4))
print(math.is_nan(math.nan))
The module accepts integers and floats where a numeric input is expected. Functions that fundamentally produce fractional results return Float.
Rounding and bounds
abs(), ceil(), floor(), round(), and trunc() are useful when converting a calculated value into a display or storage value. min() and max() choose among numeric arguments and are often used to clamp a value.
var requested = 1.8
var whole = math.ceil(requested)
var bounded = math.min(math.max(whole, 1), 4)
print(bounded)
Geometry and special values
pow(), sqrt(), cbrt(), and hypot() cover common geometric calculations. The trigonometric functions use radians. is_nan() and is_inf() help detect special floating-point values before they affect later calculations.
var distance = math.hypot(3, 4)
print(distance) # 5
print(math.sin(math.pi / 2))
print(math.is_inf(math.inf))
Some domain operations can produce nan instead of a conventional value. Check with is_nan() before serializing or displaying the result when inputs may be outside the mathematical domain.
http
The http module makes client requests. Convenience functions return a Response with status_code, header, body, and json() members.
import "http"
var response = http.get("https://example.com")
print(response.status_code)
var text = response.body.read().decode()
response.body.close()
Use response.json() when the response body contains JSON. It consumes the body, so choose either json() or body.read() for a response.
var response = http.get("https://api.example.com/items")
var items = response.json()
post(url, content_type, body), put(), and patch() send a String, Bytes, nil, or readable object as a request body. head(url) and delete(url) cover the remaining common methods without a body. Use http.Client(timeout=seconds) and its request methods when a non-default timeout is needed. Request(method, url, body) creates a custom request for client.do().
HTTP operations can raise NetworkError; JSON response parsing can raise ParseError. Always close a response body when it has not been fully consumed.
Sending JSON
Use json.marshal() to build a request body and set the matching content type. The response body is a stream, so close it after reading text or bytes.
import "http"
import "json"
var payload = json.marshal({"name": "Ada"})
var response = http.post(
"https://api.example.com/users",
"application/json",
payload
)
print(response.status_code)
response.body.close()
Requests, clients, and headers
For custom methods or headers, construct Request(method, url, body=nil), then send it through Client(timeout=seconds). Request.header supports get(), values(), set(), add(), and del().
var client = http.Client(timeout=5)
var request = http.Request("GET", "https://api.example.com/items")
request.header.set("Accept", "application/json")
var response = client.do(request)
print(response.status)
response.body.close()
The module-level functions use a finite default timeout. Treat non-success HTTP status codes as application-level results: inspect status_code before assuming that a response body contains the expected data.
Reader protocol
HTTP response bodies expose read(size=nil), close(), and the read-only
closed attribute. With no size, read() consumes all remaining bytes. With a
non-negative integer size, it returns at most that many bytes; an empty Bytes
value signals end of stream.
Objects supplied as request bodies use the same duck-typed protocol. They must
provide a callable read(size) method. Each call must return Bytes, Str, or
nil; an empty byte/string value or nil signals end of stream. A callable
close() method is optional and is invoked when the HTTP client closes the
request body. A request-body reader should therefore look like:
type Reader(chunks) {
func read(self, size) {
if self.chunks.size == 0 {
return Bytes("")
}
return self.chunks.pop(0)
}
func close(self) {
self.chunks.clear()
}
}
The size argument is a requested upper bound. A custom reader may return a
smaller chunk, but must not require callers to omit it. fs.File currently has
a separate whole-file read() API and cannot be passed directly as an HTTP
request body.
UUID
The uuid module creates and validates UUID values using
github.com/google/uuid. UUIDs are a distinct Goblin type; converting one to
a string produces its canonical lowercase representation.
import "uuid"
var id = uuid.new()
print(id)
print(uuid.is_valid("550e8400-e29b-41d4-a716-446655440000"))
var stable_id = uuid.new(
version=5,
namespace=uuid.NAMESPACE_DNS,
data="example.com",
)
API
| Function | Description |
|---|---|
UUID(value) | Constructs a UUID from a UUID, string, or 16-byte Bytes value. Raises ParseError when invalid. |
new(version=4, namespace=nil, data=nil) | Creates a UUID of version 1, 3, 4, 5, 6, or 7. |
is_valid(value) | Returns whether a string is a valid UUID representation. |
new() defaults to a random version 4 UUID. Versions 3 and 5 require both a
UUID namespace and data; data may be a string (encoded as UTF-8) or
Bytes. Those arguments are rejected for all other versions. The predefined
namespaces are NAMESPACE_DNS, NAMESPACE_URL, NAMESPACE_OID, and
NAMESPACE_X500.
UUID values expose these attributes:
| Attribute | Description |
|---|---|
bytes | The UUID's 16 raw bytes. |
urn | The UUID in urn:uuid:... form. |
version | The numeric UUID version. |
variant | The UUID variant name. |
time | Creation time as a Time, for versions 1, 6, and 7. |
clock_sequence | Clock sequence, for version 1. |
node | Six node bytes, for versions 1 and 6. |
Accessing an attribute that is not defined for the UUID's version raises
ValueError. Functions accept both positional and keyword arguments;
is_valid() requires a string.
regexp
The regexp module provides reusable regular expressions backed by Go's
RE2-based regexp package. Matching is linear in the size of the input. The
syntax deliberately excludes backreferences, lookaround, and other
backtracking-only features.
import "regexp"
var assignment = regexp.compile("(?P<key>[a-z]+)=(\\d+)")
var match = assignment.find("count=12")
print(match.group("key"))
print(match.group(2))
Only Str patterns, input, and replacements are accepted. Bytes is not
implicitly decoded or mixed with text. Compile errors are raised as
ParseError, with the Go engine's diagnostic wrapped as context.
Module API
| Function | Description |
|---|---|
compile(pattern) | Compiles pattern and returns an immutable, reusable Pattern. |
escape(text) | Quotes every metacharacter in text, so the result matches text literally. |
There are no module-level matching shortcuts. Compile once and use the resulting object, especially in loops or concurrent work.
Use escape whenever part of a pattern comes from data rather than from source
code; it is the only safe way to search for text that is not itself a pattern.
var needle = regexp.compile(regexp.escape("a.c"))
print(needle.match("abc"))
print(needle.match("a.c"))
Pattern
| Attribute or method | Result |
|---|---|
pattern | The source text this pattern was compiled from. |
group_names | Capture-group names by number, excluding group 0. |
match(text, full=false) | Reports whether a match exists. With full=true, the entire text must match. |
find(text, full=false) | Returns the first Match, or nil. With full=true, the entire text must match. |
find_all(text, count=-1) | Returns non-overlapping Match values. |
replace(text, replacement, count=-1) | Replaces matches using a template and returns a new string. |
split(text, count=-1) | Splits around matches and returns strings. |
find means leftmost substring search, and match is the same search reduced
to a boolean — the name matches path.match, which is also a boolean pattern
test. Requiring the whole text to match is an explicit option rather than a
separate method. The anchored engine full=true needs is compiled on first use,
so patterns that never ask for it pay nothing.
group_names lines up element for element with a Match's groups: entry i
is the name of group i + 1, or nil when that group is unnamed.
count means exactly what it means on the built-in str methods, so the two
families stay interchangeable: for split it is the number of pieces returned,
for replace the number of replacements made, and for find_all the number of
matches returned. 0 produces nothing, and any negative value means "no limit".
print(regexp.compile(",\\s*").split("a, b,c", count=2))
print("a, b,c".split(sep=", ", count=2))
Replacement templates use Go regexp expansion syntax: $1 and ${1} name a
numbered group, while $name and ${name} name a named group, and $$ is a
literal $. A reference to a group the pattern does not have raises
ValueError rather than silently expanding to the empty string, which would
drop text without a word. A malformed $ that begins no reference at all is
kept as literal text, following Go.
Because $1x parses as a reference to a group named 1x, it is rejected; use
${1}x to follow group 1 with literal text.
The initial API does not support callback replacements; keeping replacement deterministic and template-based avoids introducing a second execution and error-propagation model.
Match
Match is an immutable snapshot. It retains the source text and copied match
indices, so it remains usable independently of later Pattern operations.
| Attribute or method | Description |
|---|---|
source | The full text this match was found in. |
text | Text matched by group 0. |
start, end | Half-open offsets of group 0, measured in UTF-8 bytes. |
groups | Numbered capture groups excluding group 0. |
named_groups | Dict mapping each capture-group name to its text. |
group(key=0) | Returns one capture by non-negative number or name. |
span(key=0) | Returns the [start, end] offsets of one capture. |
An optional group that did not participate is represented by nil, preserving
the distinction from a participating group that matched an empty string. span
returns nil for the same reason. An unknown number or name raises
IndexError. Group 0 is available only by number. If a pattern repeats a
capture name, name lookup returns the first participating group with that name
in numeric order; it returns nil when groups with that name exist but none
participated. Numbered lookup remains unambiguous.
named_groups is a Dict, so its iteration order is unspecified; look names up
rather than printing it when output must be stable.
Offsets intentionally match Go's regexp indices and Goblin's UTF-8 string
storage: they are byte offsets, not character counts. Empty matches are kept
according to Go's FindAll rules; an empty match immediately adjacent to a
previous match is omitted. Splitting and template expansion inherit Go regexp's
empty-match behavior.
Compiled Pattern values contain no Goblin-side lock. Go's regexp.Regexp is
safe for concurrent use, and Pattern operations do not mutate it.
url
The url module follows Go's net/url parsing and escaping behavior.
| Function | Go equivalent |
|---|---|
parse(raw_url) | url.Parse |
join_path(base, elements) | url.JoinPath |
query_escape(s) / query_unescape(s) | url.QueryEscape / url.QueryUnescape |
path_escape(s) / path_unescape(s) | url.PathEscape / url.PathUnescape |
parse returns a URL with scheme, host, path, raw_query, fragment,
hostname, port, and escaped_path attributes. Its
resolve_reference(reference) method mirrors Go's URL.ResolveReference.
The reference argument must itself be a value returned by url.parse().
Malformed input raises ParseError.
import "url"
var endpoint = url.parse("https://example.com:8443/api?q=goblin#result")
print(endpoint.scheme) # https
print(endpoint.hostname) # example.com
print(endpoint.port) # 8443
print(endpoint.path) # /api
print(endpoint.raw_query)
var next = endpoint.resolve_reference(url.parse("../status"))
print(Str(next))
join_path(base, elements) accepts a base URL and a list of path elements. It
normalizes path separators while preserving the URL's scheme and host:
print(url.join_path("https://example.com/api", ["v1", "items"]))
print(url.query_escape("name=Goblin language"))
print(url.path_escape("folder/item"))
Query escaping is for a query component; path escaping is for one path
segment. Neither function builds a complete query string from a dictionary.
The corresponding unescape functions raise ParseError for invalid percent
escapes.
csv
The csv module follows Go's encoding/csv package and exposes its whole-data
operations.
| Function | Go equivalent |
|---|---|
read_all(text, ...) | Reader.ReadAll |
write_all(records, ...) | Writer.WriteAll |
read_all returns a list of string lists. Keyword arguments configure the
corresponding Go Reader fields: comma=",", comment="",
fields_per_record=0, lazy_quotes=false, and
trim_leading_space=false.
write_all accepts a list of string lists. It supports comma="," and
use_crlf=false, corresponding to Go's Writer fields. Parsing errors raise
ParseError.
By default write_all returns the CSV text. Passing dest= streams the output
into any writer object — an object with a write(data) method, such as an open
fs file — instead, and the function returns unit:
import "csv"
import "fs"
var file = fs.create("scores.csv")
csv.write_all([["name", "score"], ["Ada", "10"]], dest=file)
file.close()
import "csv"
var rows = csv.read_all("name,score\nAda,10\nGoblin,12\n")
print(rows[1][0]) # Ada
var output = csv.write_all([
["name", "score"],
["Ada", "10"],
["Goblin", "12"],
])
print(output)
The delimiter arguments must be a single valid character. With
fields_per_record=0, the reader infers the field count from the first record;
a negative value allows records of varying lengths. An empty comment disables
comments. CSV values are always strings: numeric conversion, header handling,
and mapping rows into dictionaries remain explicit application work.
base64
The base64 module converts text or bytes to Base64 text and decodes Base64 text back to Bytes. The alphabet (standard or URL-safe) and padding are independent keyword arguments.
import "x/encoding/base64"
var encoded = base64.encode("hello")
print(encoded)
print(base64.decode(encoded).decode())
API
| Function | Result | Description |
|---|---|---|
encode(data, url=false, padding=true) | str | Encode a str or Bytes value |
decode(value, url=false, padding=true) | Bytes | Decode Base64 text |
Set url=true for the URL-safe alphabet and padding=false to omit =
padding; the two compose freely (JWT-style tokens use both). decode() raises
ParseError when the input is malformed, and returns Bytes because Base64 can
represent arbitrary binary data; call .decode() on the result only when the
decoded bytes are known to contain UTF-8 text.
var token = base64.encode(Bytes([251, 255]), url=true, padding=false)
print(token)
print(base64.decode(token, url=true, padding=false))
Base64 is an encoding, not encryption. Do not use it to conceal passwords, tokens, or other secrets.
base32, ascii85, html, and quotedprintable
These modules adapt Go's value-oriented text encoding packages. They process a complete value and do not expose Go readers or writers.
base32
base32 wraps encoding/base32.
| Function | Result |
|---|---|
encode(data, hex=false, padding=true) | Encode Bytes or Str |
decode(data, hex=false, padding=true) | Decode a string to Bytes |
Set hex=true to use the extended-hex alphabet and padding=false for the
unpadded form; the two keywords compose freely and must match between
encoding and decoding. Malformed input raises ParseError.
ascii85
ascii85.encode(data) accepts Bytes or Str and returns encoded text.
ascii85.decode(data) accepts encoded text and returns Bytes. Whitespace in
encoded input follows Go's encoding/ascii85 rules. Malformed input raises
ParseError.
html
html.escape(s) replaces the five characters significant in HTML text with
entities. html.unescape(s) resolves named and numeric HTML entities. Both
accept and return Str and mirror html.EscapeString and
html.UnescapeString; this module does not parse or sanitize HTML documents.
quotedprintable
quotedprintable.encode(data) accepts Bytes or Str and returns encoded
text. quotedprintable.decode(data) accepts Bytes or Str and returns
decoded Bytes. The module wraps Go's mime/quotedprintable package using an
internal buffer so no writer object appears in the Goblin API. Invalid input
raises ParseError.
hex
The hex module follows Go's encoding/hex package.
| Function | Go equivalent |
|---|---|
encode(data) | hex.EncodeToString |
decode(s) | hex.DecodeString |
dump(data) | hex.Dump |
Encoding accepts Bytes or Str and returns lowercase hexadecimal text.
Decoding returns Bytes and raises ParseError for malformed input.
import "x/encoding/hex"
var encoded = hex.encode(Bytes("Goblin"))
print(encoded) # 476f626c696e
print(hex.decode(encoded).decode()) # Goblin
print(hex.dump(Bytes("Goblin")))
Use encode() for compact machine-readable text. dump() instead produces a
multi-line, offset-labelled representation intended for diagnostics. An odd
number of hexadecimal digits or a non-hexadecimal character makes decode()
raise ParseError; it never returns a partial result.
pem
The pem module encodes and decodes PEM blocks using Go's encoding/pem
package.
import "x/encoding/pem"
var block = pem.Block("MESSAGE", "Goblin", {"Source": "example"})
var encoded = block.encode()
var result = pem.decode(encoded)
print(result[0].label)
print(result[1])
Block
Block(label, data, headers={}) constructs a block. data accepts str or
Bytes and header keys and values must be strings.
| Member | Type | Description |
|---|---|---|
label | str | PEM block label such as CERTIFICATE |
data | Bytes | Decoded block contents |
headers | dict | Optional PEM headers |
encode() | Bytes | Encode the block, including delimiters |
The member is named label, rather than Go's Block.Type, because type is a
Goblin language keyword.
decode(data) returns [block, rest]. block is nil when no PEM block was
found; rest contains all bytes after the decoded block, allowing repeated
decoding of concatenated input.
mime
The mime module maps filename extensions and MIME types. It is useful when constructing HTTP Content-Type headers or categorizing uploaded files.
import "x/mime"
print(mime.type_by_extension(".json"))
print(mime.extensions_by_type("application/json"))
type_by_extension(extension) returns a string, or nil when the extension is unknown. Include the leading dot in the extension.
extensions_by_type(type) returns a list of known extensions. It can raise ParseError when the supplied MIME type is invalid.
Using MIME information with files and HTTP
Pass a suffix, including its leading dot, to type_by_extension(). The returned type may include a charset parameter and is nil when no mapping is known.
var filename = "report.json"
var content_type = mime.type_by_extension(".json")
if !content_type {
content_type = "application/octet-stream"
}
print(content_type)
extensions_by_type() is useful when a program accepts a declared content type and needs to show or validate the associated filename suffixes.
var image_extensions = mime.extensions_by_type("image/png")
print(image_extensions)
MIME lookup only identifies a probable media type. Do not use a filename extension alone as a security check for untrusted content.
sha256 and sha512
The sha256 and sha512 modules expose Go's fixed-size SHA-2 functions. Every
function accepts Bytes or Str. sum functions return raw digest Bytes;
the corresponding hex functions return lowercase hexadecimal text.
| Goblin function | Go equivalent |
|---|---|
sha256.sum(data) / sha256.hex(data) | sha256.Sum256 |
sha256.sum224(data) | sha256.Sum224 |
sha256.hex224(data) | sha256.Sum224, as text |
sha512.sum(data) / sha512.hex(data) | sha512.Sum512 |
sha512.sum384(data) | sha512.Sum384 |
sha512.hex384(data) | sha512.Sum384, as text |
sha512.sum224(data) / sha512.hex224(data) | sha512.Sum512_224 |
sha512.sum256(data) / sha512.hex256(data) | sha512.Sum512_256 |
Use a module's hex variant for hexadecimal text or base64.encode for
Base64 text.
SHA-2 hashes do not authenticate data or securely store passwords by themselves.
import "x/crypto/sha256"
import "x/crypto/sha512"
var digest = sha256.sum("Goblin")
print(digest.size) # 32
print(sha256.hex("Goblin"))
print(sha512.sum384("Goblin").size) # 48
Use raw Bytes when a binary format has a fixed digest field, and use a hex
function when a textual protocol explicitly expects hexadecimal. For message
authentication or password storage, use a purpose-built construction rather
than a bare hash; Goblin does not currently provide one in its standard
library.
md5, sha1, crc32, and adler32
These modules calculate complete, in-memory digests and checksums. Every
function accepts either Bytes or Str.
MD5 and SHA-1
md5.sum(data) and sha1.sum(data) return raw digest Bytes.
md5.hex(data) and sha1.hex(data) return lowercase hexadecimal text.
MD5 and SHA-1 are provided for compatibility with existing formats and protocols. They are cryptographically broken and must not be used for password storage, signatures, certificates, or other security decisions.
import "x/crypto/md5"
import "x/crypto/sha1"
print(md5.hex("Goblin"))
print(sha1.sum(Bytes("Goblin")).size) # 20 bytes
CRC-32
crc32.checksum(data, polynomial=crc32.IEEE) returns an unsigned 32-bit
checksum as a Goblin int. The module exports the IEEE, CASTAGNOLI, and
KOOPMAN polynomial constants.
Adler-32
adler32.checksum(data) returns the Adler-32 checksum as a Goblin int.
CRC-32 and Adler-32 detect accidental corruption; they do not authenticate data and are not cryptographic hashes.
import "x/hash/crc32"
import "x/hash/adler32"
print(crc32.checksum("Goblin"))
print(crc32.checksum("Goblin", polynomial=crc32.CASTAGNOLI))
print(adler32.checksum("Goblin"))
The returned checksum integers are non-negative. Choose the CRC polynomial as part of the surrounding file or wire-format contract; values calculated with different polynomials are not comparable.
hmac, crc64, and fnv
These modules wrap Go's crypto/hmac, hash/crc64, and hash/fnv
packages. They accept either str or Bytes input. Digest values are returned as
Bytes by sum() and lowercase text by hex().
import "x/crypto/hmac"
import "x/hash/crc64"
import "x/hash/fnv"
var signature = hmac.sum("secret", "message")
print(hmac.verify(signature, "secret", "message"))
print(crc64.hex("123456789"))
print(fnv.hex("hello"))
HMAC
| Function | Returns | Description |
|---|---|---|
sum(key, data, algorithm="sha256") | Bytes | Compute an HMAC digest |
hex(key, data, algorithm="sha256") | str | Compute an HMAC as hexadecimal text |
verify(signature, key, data, algorithm="sha256") | bool | Compare a raw signature in constant time |
Supported algorithms are sha256, sha512, sha1, and md5. SHA-1 and MD5
are provided only for compatibility with existing protocols. verify() expects
the raw Bytes returned by sum(), not hexadecimal text.
CRC-64
crc64.sum(data, polynomial=crc64.ECMA) and crc64.hex(...) support the
crc64.ECMA and crc64.ISO polynomial constants. Unlike crc32.checksum(),
CRC-64 returns Bytes because a Goblin int is signed and cannot represent every
unsigned 64-bit checksum.
FNV
fnv.sum(data, variant=fnv.FNV_64A) and fnv.hex(...) support the variant
constants FNV_32, FNV_32A, FNV_64, FNV_64A, FNV_128, and FNV_128A.
FNV is a non-cryptographic hash; do not use it for passwords, signatures, or
integrity checks against an attacker.
gzip, zlib, flate, and bzip2
The gzip, zlib, and flate modules adapt Go's corresponding compress
packages to whole Goblin values.
All three modules expose compress(data, level=DEFAULT_COMPRESSION, dest=unit)
and decompress(data). Input may be Bytes or Str; output is always
Bytes. Malformed compressed input raises ParseError.
Compression-level constants mirror compress/flate:
NO_COMPRESSION, BEST_SPEED, BEST_COMPRESSION, DEFAULT_COMPRESSION, and
HUFFMAN_ONLY.
By default compress returns the compressed Bytes. Passing dest= streams
the compressed output into any writer object — an object with a write(data)
method, such as an open fs file — and the function returns unit:
import "x/compress/gzip"
import "fs"
var report = "line 1\nline 2\n"
var file = fs.create("report.txt.gz")
gzip.compress(report, dest=file)
file.close()
The bzip2 module exposes only decompress(data), returning Bytes. This
mirrors Go's compress/bzip2, which provides a reader but no compressor.
import "x/compress/gzip"
import "x/compress/zlib"
var source = "Goblin Goblin Goblin"
var gz = gzip.compress(source, level=gzip.BEST_SPEED)
print(gzip.decompress(gz).decode())
var zl = zlib.compress(Bytes(source))
print(zlib.decompress(zl).decode())
The format used for decompression must match the format used for compression;
gzip, zlib, and raw flate data are not interchangeable. An unsupported level
raises ValueError, while corrupt or truncated compressed input raises
ParseError. These APIs buffer the complete input (and, without dest=, the
complete output), so they are best suited to bounded values rather than very
large files or network streams.
lzw
The lzw module wraps Go's compress/lzw package as whole-value operations.
It deliberately does not expose Go readers and writers.
import "x/compress/lzw"
var compressed = lzw.compress("Goblin data")
print(lzw.decompress(compressed))
| Function | Returns | Description |
|---|---|---|
compress(data, order=lzw.LSB, lit_width=8, dest=unit) | Bytes or unit | Compress str or Bytes data |
decompress(data, order=lzw.LSB, lit_width=8) | Bytes | Decompress a complete LZW value |
order is the lzw.LSB or lzw.MSB constant; lit_width must be from 2
through 8. Both options must match the format being read. Invalid compressed
input raises ParseError.
Passing dest= streams the compressed output into any writer object — an
object with a write(data) method, such as an open fs file — and compress
then returns unit.
tar and zip
The tar and zip modules adapt Go's archive/tar and archive/zip packages
to complete in-memory archives.
Both modules provide write_all(files) and read_all(data). files is a
dictionary whose string keys are archive paths and whose values are Bytes or
Str. write_all returns archive Bytes; read_all returns a dictionary of
file names to Bytes. Directory and other non-regular entries are skipped when
reading.
write_all also accepts dest=: the archive then streams into any writer
object — an object with a write(data) method, such as an open fs file — and
the function returns unit instead of Bytes:
import "x/archive/tar"
import "fs"
var file = fs.create("backup.tar")
tar.write_all({"notes.txt": "remember"}, dest=file)
file.close()
zip.write_all accepts method=zip.DEFLATE and also supports zip.STORE.
Malformed archives raise ParseError.
import "x/archive/zip"
var archive = zip.write_all({
"README.txt": "Goblin archive",
"data/raw.bin": Bytes("abc"),
})
var files = zip.read_all(archive)
print(files["README.txt"].decode())
Use method=zip.STORE when entries are already compressed or must be stored
verbatim; the default zip.DEFLATE generally produces smaller archives.
tar.write_all(files) has the same dictionary input but no compression-method
argument.
Archive paths are taken from the dictionary keys. Validate untrusted names
before writing returned entries to disk: read_all() keeps archive names and
does not choose a safe extraction directory for the application. Duplicate
entry names collapse to one dictionary key when reading.
These whole-archive operations correspond to iterating Go Reader and Writer
entries. Entry contents are still assembled in memory even with dest=; a
future streaming reader protocol can add incremental access without changing
the archive format or these convenience operations.
The mail module wraps the address parsing portion of Go's net/mail package.
It deliberately omits message parsing: Go's message API is stream-shaped and a
larger Goblin mail-message abstraction has not been designed.
| Member | Description |
|---|---|
Address(name, address) | Construct an address value |
parse_address(s) | Parse one RFC 5322-style address |
parse_address_list(s) | Parse a comma-separated address list into a List |
Malformed input raises ParseError.
An Address exposes read-only name and address attributes. Converting it to
text produces Go's correctly quoted and encoded mailbox representation.
Its constructor attribute is the same callable as mail.Address.
import "x/net/mail"
var recipient = mail.parse_address("Goblin <goblin@example.com>")
print(recipient.name)
print(recipient.address)
print(Str(recipient)) # Goblin <goblin@example.com>
Address(name, address) does not send mail or validate that the destination
exists. It constructs a correctly formatted mailbox value. Use an empty name
for a bare address:
var sender = mail.Address("", "sender@example.com")
var recipients = mail.parse_address_list(
"Ada <ada@example.com>, goblin@example.com"
)
for recipient in recipients {
print(recipient.name, recipient.address)
}
Address parsing accepts the syntax supported by Go's net/mail parser,
including quoted display names and encoded words. Invalid mailbox syntax raises
ParseError. The module intentionally provides address values only; composing,
parsing, or transmitting complete messages is outside its current API.
netip
The netip module wraps Go's immutable net/netip address and prefix values.
It performs parsing and address calculations without opening network sockets.
import "x/net/netip"
var addr = netip.Addr("192.168.1.10")
var network = netip.Prefix("192.168.1.5/24")
print(addr.is_private)
print(network.masked())
print(network.contains(addr))
Addr
Construct an address with Addr(text). Its members are bits, bytes,
zone, is4, is6, is_loopback, is_multicast, is_private,
is_unspecified, is_link_local_unicast, and is_link_local_multicast.
Methods next(), prev(), and unmap() return new Addr values.
Addr values compare in Go's canonical address order. Moving beyond the first
or last address with prev() or next() raises ValueError.
Prefix
Construct a prefix with Prefix(text). It exposes addr, bits, and
is_single_ip, plus these methods:
| Method | Returns | Description |
|---|---|---|
contains(addr) | bool | Report whether the address lies in the prefix |
overlaps(prefix) | bool | Report whether two prefixes overlap |
masked() | Prefix | Return the canonical network prefix |
utf8 and unicode
The utf8 module works with UTF-8 byte sequences, while unicode classifies and maps one Unicode character at a time.
UTF-8
| Function | Returns | Description |
|---|---|---|
valid(data) | bool | Check whether str or Bytes contains valid UTF-8 |
rune_count(data) | int | Count decoded Unicode code points |
encode(codepoint) | Bytes | Encode an integer Unicode code point |
decode(data) | list | Decode the first code point as [codepoint, byte_count] |
decode() raises ValueError for empty input and ParseError when the first byte
sequence is invalid.
Unicode characters
The predicates is_letter, is_digit, is_number, is_space, is_upper,
is_lower, and is_control accept a string containing exactly one Unicode
character. The mapping functions to_upper, to_lower, and to_title return
one mapped character.
import "x/unicode/utf8"
import "x/unicode"
print(utf8.rune_count("Goblin 👺"))
print(unicode.is_letter("界"))
print(unicode.to_upper("é"))
Extending Goblin with Go
Goblin is implemented in Go, and its runtime values are Go values. This makes the standard library extension model straightforward: write Go code that implements the runtime contracts, expose it as a module or built-in function, then make both Goblin execution backends aware of it.
This chapter is for contributors extending the Goblin repository, not for ordinary Goblin programs. The extension code imports the repository packages:
import (
"github.com/aisk/goblin/object"
)
The runtime boundary
Every Goblin value implements object.Object. Integer, Float, String, List, Dict, module values, functions, and user-defined Goblin types all appear to the Go runtime through this interface. It supplies conversion, comparison, operators, iteration, indexing, and attribute access.
This single interface is why a Go extension can participate naturally in Goblin expressions. A custom value can decide how it prints, whether it is truthy, what an attribute lookup returns, and which operators are valid. Custom object types explains the contract in detail.
Adding a function or module
A Go-callable Goblin function has this shape:
func(args object.CallArgs) (object.Object, error)
Place related functions in an extension package and return them from an object.Module:
func ExecuteExample() (object.Object, error) {
return &object.Module{
Members: map[string]object.Object{
"greet": &object.Function{Name: "greet", Fn: greet},
},
}, nil
}
For a new built-in module, register its executor in both interpreter/imports.go and transpiler/transpiler.go. The interpreter registry makes import "example" work with goblin run. The transpiler knownModules table ensures goblin build-exe imports and initializes the same module. Keeping both registrations in sync is essential for behavior parity.
Add a focused Go test for the extension and a Goblin example when its user visible behavior needs end-to-end coverage.
Choosing an extension shape
Use a plain object.Function for a stateless operation such as a conversion or utility. Use object.Module for a named collection of functions and constants. Use a custom object.Object type when the feature has its own state, methods, or protocol behavior, such as a Path, HTTP response, file, or time value.
The next chapters show custom values and safe argument parsing.
Custom object types
Every runtime value in Goblin is a Go implementation of object.Object. The interface is the contract between a value and the interpreter or transpiler. It includes these groups of methods:
| Group | Object methods |
|---|---|
| Display and conversion | ToString(), ToBool() |
| Comparison and operators | Equals(), Compare(), Add(), Minus(), Multiply(), Divide(), Modulo(), Not() |
| Reflected operators | RAdd(), RMinus(), RMultiply(), RDivide(), RModulo() |
| Collection protocols | Iter(), Index() |
| Members | GetAttr(), Attributes(), SetIndex(), SetAttr() |
| Identity | TypeName() |
The reflected operators are required, but most types have nothing to say through them: embed object.NoReflectedOps and they all answer "not handled".
Types should also implement String() string, satisfying fmt.Stringer. It is not part of the interface, but diagnostics and formatting use fmt.Stringer and fall back to TypeName() when String is not available. ToString is the failing counterpart that may run a user-defined __str.
Assignment is part of the interface too: SetIndex and SetAttr return a bool saying whether the value accepts that form of assignment at all. A value that accepts neither embeds object.NoAssignment and says nothing.
Start with a Go struct
This excerpt shows the state, conversion, and member portion of a Counter value. A complete implementation must also provide every remaining object.Object method listed above. Unsupported operators should return the standard TypeError rather than silently accepting an operation.
type Counter struct {
object.NoReflectedOps // nothing completes an operation from Counter's right
object.NoAssignment // counter[i] = x and counter.f = x are not accepted
Value int64
}
func (c *Counter) TypeName() string { return "Counter" }
func (c *Counter) String() string { return fmt.Sprintf("Counter(%d)", c.Value) }
func (c *Counter) ToString() (string, error) { return c.String(), nil }
func (c *Counter) ToBool() (bool, error) { return c.Value != 0, nil }
func (c *Counter) GetAttr(name string) (object.Object, error) {
switch name {
case "value":
return object.Integer(c.Value), nil
case "increment":
return &object.Function{Name: "increment", Fn: c.increment}, nil
case "attributes":
return object.AttributesFunction(c), nil
default:
return nil, object.NewAttributeError("Counter has no attribute '%s'", name)
}
}
func (c *Counter) Attributes() []string {
return []string{"attributes", "increment", "value"}
}
func (c *Counter) increment(args object.CallArgs) (object.Object, error) {
if err := object.RequireNoKeyword("increment", args); err != nil {
return nil, err
}
if len(args.Positional) != 0 {
return nil, object.NewTypeError("increment() takes no arguments")
}
c.Value++
return object.Integer(c.Value), nil
}
The receiver-bound object.Function is the key pattern: Goblin evaluates counter.increment() by looking up increment and then calling the returned function. It can safely mutate the Go receiver.
For example, a Counter that does not support addition should implement Add by returning object.NewTypeError. Apply the same principle to the other unsupported protocol methods.
func (c *Counter) Add(other object.Object) (object.Object, error) {
return nil, object.NewTypeError("cannot add Counter and %s", other.TypeName())
}
Define protocol behavior deliberately
Return a useful result for supported operations and a TypeError for unsupported ones. For example, a Vector can implement Add and Compare while a Counter may only need display, truthiness, and members.
func (v Vector) Add(other object.Object) (object.Object, error) {
right, ok := other.(Vector)
if !ok {
return nil, object.NewTypeError("cannot add Vector and %s", other.TypeName())
}
return Vector{X: v.X + right.X, Y: v.Y + right.Y}, nil
}
Compare returns a negative value, zero, or a positive value. Iter returns a slice of object.Object values. Index must verify that its index is an object.Integer and return IndexError for an invalid position.
Name types in messages with TypeName
Use value.TypeName(), not %T, when an error mentions the type of a value. A Go
type name is an implementation detail that differs between the two backends — a
user-defined Point is *interpreter.instance under goblin run and *main.Point
in a compiled program — so %T makes the same program report different messages
depending on how it was executed. TypeName returns the Goblin-level name, which
is why every type declares it:
func (v Vector) TypeName() string { return "Vector" }
Embedding is not enough here: a type that embeds another for its defaults inherits that type's name too, which is exactly the wrong answer.
Operators dispatch through package-level helpers
The operators do not call Equals, Compare, Add and friends on the left operand
directly: both backends go through object.Equals(a, b), object.Compare(a, b),
object.Add(a, b), object.Minus(a, b), object.Multiply(a, b) and
object.Divide(a, b), and object.Modulo(a, b), which also give the right operand
a chance to answer.
A custom type therefore only has to recognize the types it knows, and its
Compare is reached even when it appears on the right of 1 < value. Report an
unfit pair with object.NewTypeError so the reflected attempt is made; any other
error propagates as-is. Equals follows the same rule with its own error return:
a TypeError from it means "not this type" and leaves == total, while any
other error fails the comparison.
Arithmetic reaches the right operand through RAdd, RMinus, RMultiply, RDivide
and RModulo, the Go side of __radd and friends. Their argument is the LEFT
operand — RMinus(left) computes left - receiver — and the bool they return
reports whether the receiver handled this operand at all; returning false
leaves the left operand's error in place. Embed object.NoReflectedOps and
override only the operator a type completes from the right, the way
object.String and object.List override RMultiply to make 3 * "ab" work:
type Vector struct {
object.NoReflectedOps
X, Y float64
}
// `2 * v` — scaling reads the same in either operand order.
func (v Vector) RMultiply(left object.Object) (object.Object, bool, error) {
n, ok := left.(object.Integer)
if !ok {
return nil, false, nil
}
return Vector{X: v.X * float64(n), Y: v.Y * float64(n)}, true, nil
}
For a complete reference implementation, read the existing runtime types in object/, especially path.go, list.go, dict.go, bytes.go, and chan.go. They show how to report errors consistently and how to expose methods through GetAttr.
Expose a constructor with stable type identity
Most custom values need a constructor added to a module or to the built-ins
map. Use object.NewNativeConstructor so the exported callable and every
instance share one stable identity, matching Goblin-defined types:
var CounterType = object.NewNativeConstructor(
"Counter",
func(args object.CallArgs) (object.Object, error) {
p := object.NewArgParser("Counter", args)
start := p.IntOr("start", 0)
if err := p.Finish(); err != nil {
return nil, err
}
return &Counter{Value: int64(start)}, nil
},
)
Place CounterType.Function in the module members map. The value's GetAttr
and Attributes methods must delegate the constructor member to the same
helper:
func (c *Counter) GetAttr(name string) (object.Object, error) {
if value, ok := CounterType.Attribute(name); ok {
return value, nil
}
switch name {
case "value":
return object.Integer(c.Value), nil
case "increment":
return &object.Function{Name: "increment", Fn: c.increment}, nil
case "attributes":
return object.AttributesFunction(c), nil
default:
return nil, object.NewAttributeError("Counter has no attribute '%s'", name)
}
}
func (c *Counter) Attributes() []string {
return CounterType.Attributes("attributes", "increment", "value")
}
Goblin can then use the same constructor identity check for native, built-in, and source-defined values:
var counter = module.Counter(start=10)
print(counter.constructor == module.Counter) # true
Create the helper once at package scope. Constructing it during every module load would give the same native type multiple identities. See Functions and arguments for the argument parser used by the constructor.
Functions and arguments
A Go function visible to Goblin is an object.Function. Its Fn callback receives object.CallArgs and returns either a runtime value or an error.
var Greet = &object.Function{
Name: "greet",
Fn: greet,
}
func greet(args object.CallArgs) (object.Object, error) {
p := object.NewArgParser("greet", args)
name := p.Str("name")
excited := p.BoolOr("excited", false)
if err := p.Finish(); err != nil {
return nil, err
}
suffix := "."
if excited {
suffix = "!"
}
return object.String("Hello, " + string(name) + suffix), nil
}
This accepts both greet("Ada") and greet(name="Ada", excited=true). The order of parser calls defines the positional argument order. Finish is mandatory: it reports unconsumed positional arguments and unexpected keyword arguments.
ArgParser accessors
NewArgParser accumulates the first argument error, letting the function extract all of its inputs before checking once at Finish.
| Accessor | Meaning |
|---|---|
| Any(name) / AnyOr(name, default) | Required or optional arbitrary Object |
| Int, Float, Str, Bool | Required typed value |
| IntOr, FloatOr, StrOr, BoolOr | Optional typed value with a default |
| Number / NumberOr | Integer or Float |
| Float64 | Numeric value converted to Go float64 |
| Func | A Goblin function |
| OptionalAny | Value plus whether it was supplied |
| Rest | All remaining positional values |
Use OptionalAny when omitted and explicitly passing nil have different meanings. Use Rest for an open-ended positional tail.
func sum(args object.CallArgs) (object.Object, error) {
p := object.NewArgParser("sum", args)
values := p.Rest()
if err := p.Finish(); err != nil {
return nil, err
}
var total int64
for _, value := range values {
n, ok := value.(object.Integer)
if !ok {
return nil, object.NewTypeError("sum() values must be int")
}
total += int64(n)
}
return object.Integer(total), nil
}
Other binding helpers
For a function that permits positional arguments only, call object.RequireNoKeyword before checking the positional count. This is useful for small no-options methods.
BindArguments is useful when an extension needs a declared parameter list plus varargs or keyword captures. It binds positional and named values, detects duplicates, and returns a map of parameter names to Object values.
bound, err := object.BindArguments(
"inspect",
[]string{"name"},
"rest",
"options",
args,
)
if err != nil {
return nil, err
}
name := bound["name"].(object.String)
rest := bound["rest"].(*object.List)
options := bound["options"].(*object.Dict)
Always return object.NewTypeError or another runtime error constructor for user-facing failures. This preserves Goblin error handling and produces useful tracebacks in both the interpreter and compiled executable.