Getting Started with Zirric

Getting Started

Zirric is a declaration-driven language with an expression-first feel. It aims to stay small while keeping enough structure to build real programs. Values are dynamic, but conversions are explicit, and behavior is described through attributes rather than interfaces.

Experimental

Zirric is still evolving. Some features are specified but not fully implemented yet. Use the proposals for authoritative intent. The standard library covers I/O and the common data types — io, fmt, os, strings, arrays, dicts, math, json and more — but names and signatures can still change, so expect gaps and changes.

Your first program

After installing Zirric, put this in main.zirr:

import fmt
import os

fmt.fprintln("Hello, Zirric!", os.stdout())
$ zirric run main.zirr
Hello, Zirric!

No project or Cavefile is needed for a single file. See the Zirric CLI for the rest of the commands.

What Zirric emphasizes

  • Data and union types for structured modeling
  • Attributes as the primary capability mechanism
  • Closed declarations for least surprise
  • Modules as the unit of organization and import
  • First-class functions with concise syntax
  • Expression-first control flow (if, for)

The Zirric mindset

Your development starts with a new module. You simply create a new folder and place your files there. Then you begin defining the shape of your data. Declare every data type you need and group them into unions. Try to make your data match your mental model of the relationship between the data types and the unions.

If the union itself is the important thing and your data types are just an implementation detail, nest them. If the union is just supportive, make the data top level. This communicates how important these structures are.

For example, let’s model a simple binary tree, that highlights the relationship between data and union. The union is the important thing here, so we nest the data declarations inside it.

union BinaryTree {
	data Branch {
		left
		right
	}

	data Leaf { value }
}

Now that we modeled our domain, we can start writing functions that operate on our BinaryTree. For example, a function to calculate the depth of the tree:

import math

fn depth(tree: BinaryTree) -> Int {
	return switch tree {
	case is Branch:
		1 + math.max(depth(tree.left), depth(tree.right))
	case is Leaf:
		1
	}
}

Once we want to integrate our BinaryTree with other parts of our codebase, we can add attributes that describe its capabilities. For example, we could add a Countable attribute that allows us to count the number of leaves in the tree:

// this could be defined in another module
attr Counted {
	count(value: @Counted) -> Int
}

fn count(val: @Counted) -> Int {
	return Counted(val).count(val)
}

// in your module

union BinaryTree {
	@Counted(fn(tree) {
		return count(tree.left) + count(tree.right)
	})
	data Branch {
		left
		right
	}

	@Counted(fn(tree) {
		return 1
	})
	data Leaf { value }
}

Each member carries its own implementation. A value of the union finds the one belonging to its concrete type, so count works on a whole tree without the union itself having to know how counting is done.

In the same way we could also add attributes for JSON parsing. Then the JSON parsing library would lookup your attributes like @Key or @Default to figure out how to parse your data.

Declarations at a glance

Zirric code is built from a small set of declarations:

mod myapp

import strings

attr Returns {
	type
}

const answer = 42

fn greet(name) {
	return "Hello, " + name
}

data Person {
	name
	age
}

union Result {
	data Ok { value }
	data Err { message }
}

Values and literals

Zirric supports basic literals you should be familiar with:

42 // Int
3.14 // Float
true // Bool
"Hello" // String
[1, 2, 3] // Array
["key": "value"] // Dict
fn(a, b) { return a + b } // Function literal

Variables and functions

Declare constants with const, variables with var and functions with fn.

const answer = 42

fn greet(name) {
	return "Hello, " + name
}

const message = greet("Zirric")

Functions are values and can be passed around like any other expression:

fn applyTwice(f, value) {
	return f(f(value))
}

Data and unions

Zirric models records with data and tagged unions with union. Union members can be nested data declarations for structured variants.

data Person {
	name
	age
}

union Result {
	data Ok { value }
	data Err { message }
}

const person = Person("Avery", 30)
const ok = Ok("Done")

prelude ships two such unions, Option (Some/None) and Result (Ok/Err), along with operators for working through them. ?. reads a field off what an option holds, unless it is absent, in which case the whole chain is None; !. does the same for a result, and returns the error from the enclosing function instead. ?? and !! then stand in for a value that is not there, and T? and T! name the two types in a signature. None of them needs an unwrapping step written out.

fn nameOf(person: Person?) -> String {
	return person?.name ?? "Anonymous"
}

fn describe(found: Person!) -> String! {
	const name = found!.name
	return Ok("This is " + name)
}

A union of your own joins in by carrying @AnyOption or @AnyResult, whose callback says how to read it as the standard one.

See Expressions § Guarded Member Access and § Fallback.

Control flow

if and for come in expression and statement forms. Expression forms return values; statement forms are for side effects.

import fmt
import os

const out = os.stdout()

const status = if answer == 42 {
	"yes"
} else {
	"no"
}

if answer == 42 {
	fmt.fprintln("yes", out)
} else {
	fmt.fprintln("no", out)
}

for item <- [1, 2, 3] {
	fmt.fprintln(item, out)
}

const oddNumbers = for item <- [1, 2, 3] {
	if item % 2 != 0 {
		item
	} else {
		continue // skip to next iteration
	}
}

// oddNumbers is [1, 3]

Attributes and capabilities

Zirric does not use interfaces. Instead, attributes describe capabilities and attach metadata to declarations. They are a core part of the language and tooling story.

attr Countable {
	length(value: @Countable) -> Int
}

@Countable(fn(v) { return v.length })
data Bag {
	items
	length
}

Attributes are central to tooling, defaults, and protocol-like behavior.

Modules and imports

Zirric code is organized into modules. Every file declares the module it belongs to with mod, by its fully qualified path, and uses import to reach other modules.

mod myapp.http

import fmt

fn statusLine(code) {
	return "HTTP " + fmt.sprint(code)
}

fmt.fprintln writes to a writer such as os.stdout() and fmt.sprint turns any value into a string; strings, math, arrays and the rest are imported the same way. The scripts package, which lives outside the standard library, pairs those functions with standard output for you.

What Zirric avoids

  • Interfaces or inheritance as a primary abstraction.
  • Implicit conversions between types.
  • Generics. Types should be easy to reason about.
  • Scattering members and capabilities across multiple declarations.

Zirric favors explicit declarations and attributes instead.

Learn more