HollowScript is a statically typed, statement-oriented programming language with full type inference, closures, generics, an immutable data model, and a capability system that keeps every interaction with the outside world declared and visible. Scripts are plain text files saved with a .hws extension.
HollowScript powers automation inside Hollow Cascade and is also available as a standalone tool. The same language runs in both: write a script once and run it from the command line, embed it in a Cascade step, or drive it from any TypeScript or JavaScript host via the published library.
HollowScript is available as a standalone binary that requires no Node.js installation, and as a library for embedding in a TypeScript or JavaScript application.
One command installs the standalone binary and verifies its checksum before writing anything to disk.
# macOS and Linux curl -fsSL https://storage.googleapis.com/sstk-hollow-hollowscript-releases/install.sh | sh # Windows irm https://storage.googleapis.com/sstk-hollow-hollowscript-releases/install.ps1 | iex
Do not download those binaries in a browser on macOS: they are not yet code-signed, and macOS quarantines browser-downloaded binaries on sight. The curl install path does not trigger quarantine.
hollowscript --version hollowscript run hello.hws hollowscript check hello.hws hollowscript format hello.hws hollowscript repl
The CLI grants print, clock and random by default and nothing else. Running a script someone gave you cannot touch your filesystem or the network, regardless of what its uses line requests.
HollowScript is statically typed with inference: types are checked at compile time and most of them are worked out automatically, so you only need to write a type annotation where the language cannot determine it itself.
Type annotations are required on function parameters, on a function's return type when it returns a value, and on declarations whose initial value gives no type information (an empty list literal, or the literal none). Everywhere else, the type is inferred.
Arithmetic operators + - * stay Int when both sides are, and produce Float the moment either side is. Division / always produces Float. + never concatenates strings; use interpolation or join on a list of strings instead.
A complete HollowScript program, annotated against a real example.
// uses declares every capability this script may reach
uses tasks, print
// constant binds a name once; variable binds a name that can be reassigned
constant filter: Filter = `status:open priority:high`
constant urgent = effect tasks.where(filter)
for task in urgent {
match task.owner {
none { effect print("Unassigned: ${task.title}") }
else { effect print("Urgent: ${task.title}, owned by ${task.owner}") }
}
}
return urgent.length()Named functions are declared with fn. Parameter types and the return type (when there is one) are always written explicitly.
fn add(a: Int, b: Int): Int {
return a + b
}
return add(2, 3)A function that declares a return type must return a value on every path through it. A function that declares no return type must never return a value; it is called for its effects.
Closures are unnamed functions used as values. They have the same syntax as a named function, minus the name, and they capture the values in their enclosing scope at the time they run:
constant makeAdder = fn(n: Int): fn(Int): Int {
return fn(x: Int): Int {
return x + n
}
}
constant addFive = makeAdder(5)
return addFive(10)Generics use angle-bracket syntax on function declarations. Type parameters are inferred at the call site and never written explicitly by the caller:
fn identity<T>(value: T): T {
return value
}
return identity("hello")Recursion is supported. The runtime enforces a call-depth limit to prevent runaway recursion from overflowing the host's own call stack; exceeding it stops the script with a reported failure rather than a silent crash.
A record is a fixed set of named fields. Records are structural: compatibility is based on field names and types, not on what name a type was declared with.
record Point = { x: Int, y: Int }
constant origin: Point = { x: 0, y: 0 }
constant moved: Point = { x: origin.x + 1, y: origin.y }
return "${moved.x}, ${moved.y}"There is no constructor call and no class. A record literal is just { field: value, … }, checked against the type it is used as. A missing field or an unknown field is a compile error.
A type written as T? may also hold none. Optionals do not nest.
record Task = { title: String, owner: String? }
fn describe(task: Task): String {
if task.owner != none {
return "${task.title}, owned by ${task.owner}"
}
return "${task.title}, unowned"
}
return describe({ title: "Ship it", owner: "Lee" })The check task.owner != none does more than compare: inside that branch, task.owner is narrowed from String? to String, so it can be used directly with no further check. The same narrowing applies after a guard clause that returns early, and in match branches with a none arm.
Recursive records are fully supported: a record may contain an optional field of its own type.
fn describe(n: Int): String {
match n {
0 { return "zero" }
1, -1 { return "one, either sign" }
else { return "something else" }
}
}
variable total = 0
variable i = 0
while i < 5 {
if i == 3 { break }
total = total + i
i = i + 1
}
return "${describe(1)}, total ${total}"Unused names declared with constant or variable produce a warning. Shadowing a name already in scope is a compile error anywhere in the file.
Five pure modules ship with the language itself. None of them touches anything outside the script, so none of them need a uses declaration. They are always available.
length(): Int contains(part: String): Bool startsWith(prefix: String): Bool endsWith(suffix: String): Bool toUpper(): String toLower(): String trim(): String split(separator: String): [String] replace(from: String, to: String): String // every occurrence substring(start: Int, upTo: Int): String // start inclusive, upTo exclusive, clamped
abs, min and max preserve Int when every argument is an Int, and otherwise return Float.
round(x: Float): Int floor(x: Float): Int ceil(x: Float): Int sqrt(x: Float): Float pow(base: Float, exponent: Float): Float abs(x): same type as x min(a, b): Int if both Int, otherwise Float max(a, b): Int if both Int, otherwise Float remainder(a: Int, b: Int): Int range(from: Int, upTo: Int): [Int] // empty when from >= upTo
sort() requires elements of type Int, Float, or String; use sort(compare:) for any other element type. The comparator follows the usual convention: negative when its first argument sorts before its second, zero when equal, positive when after. join requires [String].
length(): Int isEmpty(): Bool contains(item: T): Bool first(): T? last(): T? append(item: T): [T] // returns a new list concat(other: [T]): [T] map<U>(transform: fn(T): U): [U] filter(keep: fn(T): Bool): [T] each(action: fn(T)) reduce<A>(initial: A, combine: fn(A, T): A): A sort(): [T] // T must be Int, Float or String sort(compare: fn(T, T): Int): [T] reverse(): [T] join(separator: String): String // T must be String
keys(r): [String] has(r, name: String): Bool
success<T>(value: T): Result<T> failure<T>(message: String): Result<T>
HollowScript has no built-in way to print, read the clock, generate a random number, read a file, or make a network call. Every interaction with the world beyond a script's own computation is a capability: something whoever runs the script grants explicitly, before it starts.
Any host that provides these provides them with these exact shapes, so a script written against one host reads the same against another.
uses print, clock, random
effect print("the time is ${effect clock.now()}")
constant roll = effect random.int(1, 7)
return rollHollow Cascade grants two additional capabilities beyond the three standard ones.
uses print, clock, tasks
constant urgent = effect tasks.where(`priority:high status:todo`)
effect print("${urgent.length()} urgent tasks at ${effect clock.today()}")A file can import names from another file by relative path, without the .hws extension. Imported names bind directly, with no renaming or wildcards.
// main.hws
import double from "./double"
return double(21)
// double.hws
export fn double(x: Int): Int {
return x * 2
}The keywords export and import apply to fn, record, and constant declarations. Top-level statements and variable declarations cannot be exported.
uses lines are not inherited through an import. Each file states its own capabilities, so reading the top of one file always tells you everything it can touch outside its own computation, without needing to trace its imports.