Type System

Type System

Zirric is dynamically typed but strongly typed. Values carry their type at runtime and can flow through any variable, field, or parameter. There are no implicit conversions — a String is never silently treated as an Int. Type information is communicated through type hints, which are optional but binding where they are written: analysis reports a hint a value cannot satisfy, and is expressions and switch matching check types at runtime.

This page covers the type categories, their construction and identity rules, type hints, runtime type checking, and protocol attributes.

Type Categories

Zirric has four categories of user-declared types and a set of built-in extern types.

Category Declared with Constructible from Zirric Runtime identity
Data types data Yes (constructor call) Nominal
Union types union No (members are) Membership-based
Attribute types attr Only via @Attr() syntax Nominal
Extern types extern type No (runtime-provided) Nominal

For declaration syntax, see Declarations.

Data Types

Data types are the most common types in Zirric. They store structured data as named fields.

data Person {
	name: String
	age: Int
}

Construction. Call the type name as a function with positional arguments matching the field order:

const alice = Person("Alice", 30)

Identity. Each data declaration creates a distinct type. Two data types with identical fields are not the same type. Type identity is nominal — it is determined by the declaration, not the structure.

Field access. Fields are accessed with .:

alice.name // "Alice"

Equality. Two data instances are equal (==) if they have the same type and all fields are equal.

Union Types

Union types declare that a value may be one of a fixed set of member types. Members can be existing types or inline data declarations.

union Option {
	data Some { value }
	data None
}

Membership. A value belongs to a union if its concrete type is one of the declared members. Membership is checked at runtime. A value does not need to be “wrapped” in the union — if its type is a member, it is a member.

const x = Some(42)
x is Option // true — Some is a member of Option
x is Some // true — x is directly a Some

Nested declarations. data types declared inside a union body are hoisted to module scope. They exist as standalone types and as union members simultaneously.

Discrimination. Union values are typically discriminated using switch with is cases:

fn unwrap(opt: Option) {
	switch opt {
	case is Some:
		opt.value
	case is None:
		void
	case _:
		void
	}
}

See Expressions § Switch for the full matching semantics.

Unions of unions. A union can reference another union as a member. A value belongs to the outer union if it belongs to any of its members, transitively.

Extern Types

Extern types are built-in types provided by the runtime. They cannot be constructed from Zirric code — instances are created by literals, declarations, or runtime operations.

Type Created by Fields
Int Integer literals —
Float Float literals —
Bool true, false —
String String literals chars() -> [Char]
Char Iterating a String, or chars() —
Binary Runtime operations, bytes —
Byte Indexing a String or a Binary, or iterating a Binary —
Array Array literals —
Dict Dict literals keys() -> Array
Func fn declarations, closures name: String, arity: Int
Void void constant —
Any — —
AnyType — —
Attribute @Attr() application the attribute’s own fields
AttributeType attr declarations —
AnyModule mod declarations (members)
ModuleType — —

The four Any…/…Type entries are the types the others are described in terms of: every value is an Any, every type is an AnyType, every attribute is an Attribute, and every module is an AnyModule. They are what a hint names when it must accept anything of that kind — reflect.members(m: AnyModule) -> [Any], for instance.

len(value) counts a String, Array, Dict or Binary through @Countable rather than through a length field.

Field access on extern types. Some extern types expose fields and methods (like String.chars()). These are accessed with . just like data fields.

For the full list with documentation, see Standard Library § Prelude.

Attribute Types

Attribute types are declared with attr and follow the same field syntax as data. However, they differ in a critical way: attribute instances can only be created through the @Attr(args) application syntax on declarations or fields. They cannot be called as constructor functions at runtime.

attr Table {
	name: String
}

@Table("people")
data Person { name }

Role in the type system. Attribute types participate in type checking through is @Attr expressions and switch case is @Attr patterns. When you write value is @Table, the runtime checks whether the value’s type declaration carries the @Table attribute — not whether the value itself is a Table instance.

For how to declare and apply attributes, see Declarations § attr and Declarations § Attributes on Declarations.

Protocol attributes

A common pattern in Zirric is to use attributes to describe type capabilities, similar to interfaces or protocols in other languages. The prelude defines attributes like @Countable and @Iterable that the runtime uses to drive behavior.

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

attr Iterable {
	iterate(value: @Iterable, yield: fn(Any) -> Bool)
}

Types opt into a protocol by applying the attribute with a function implementation:

@Countable(_arrayLen)
@Iterable(_arrayIterate)
extern type Array {}

How for uses @Iterable. When a for loop iterates over a value, the runtime looks up the @Iterable attribute on the value’s type and calls the iterate function. The function receives the value and a yield callback. It calls yield with each element; if yield returns false, iteration stops (implementing break).

How len uses @Countable. The built-in len operation looks up the @Countable attribute on the value’s type and calls the length function.

Matching on attributes

is @Attr checks whether a value’s type carries the attribute, not whether the value is an attribute instance:

const items = [1, 2, 3]
items is @Iterable // true — Array has @Iterable
items is @Countable // true — Array has @Countable

This also works in switch:

switch value {
case is @Iterable:
// value's type has @Iterable
case _:
	// fallback
}

Accessing attributes at runtime

Attribute values attached to a type can be accessed at runtime through the reflect module:

import reflect
import coding

data Person {
	@coding.Name("user_name")
	name: String
}

const personType = reflect.typeOf(person) ?? void
const fields = reflect.fieldsOf(personType)

const key = switch fields[0] {
case is @coding.Name:
	coding.Name(fields[0]).text
case _:
	fields[0].name
}

An attribute type is called on a value to read the attribute written on that value’s declaration — coding.Name(field).text above. reflect.typeOf returns an Option, since not every value has a type with a runtime representation. See reflect for the full surface.

Type Hints

Type hints annotate declarations and parameters with type information. They appear after : on fields, parameters, and variables, and after -> on function return types.

fn greet(name: String) -> String {
	"Hello, " + name
}

data Pair {
	first: Int
	second: Int
}

const x: Int = 42

What type hints express

Type hints communicate intended types to readers, editors, and the language server, and the compiler records them for is checks and switch matching. They are also binding: where a hint is written, analysis reports a value that cannot satisfy it — a wrong argument, a returned value that contradicts ->, an initializer or assignment that contradicts :, or an attribute constraint the value’s type does not carry.

Hints stay optional, and the checker reports only what it is certain of. Where it cannot infer a type — an unhinted parameter, a mixed collection, a value that arrives from a call with no declared return — anything fits, and the program runs as it always did. Writing a hint is what turns a mistake from a runtime failure into a reported one.

Composite type hints

Beyond simple type references, type hints support composite forms:

Form Syntax Meaning
Named type Type A reference to a declared type
Qualified type mod.Type A type from another module
Array type [Element] An array of elements
Dict type [Key: Value] A dictionary with key and value types
Function type fn(P) -> R A function with parameter and return types
Attribute type @Attr A value whose type carries @Attr
Multi-attribute @Attr1 @Attr2 A value whose type carries all listed attributes
fn transform(items: [Int], f: fn(Int) -> String) -> [String] {
	return for item <- items {
		f(item)
	}
}

fn process(value: @Countable @Iterable) {
	// value's type must have both attributes
}

Optional and Result Shorthands

T? and T! are shorthands for the two prelude unions that wrap a value:

fn findUser(id: Int) -> User? { ... } // returns an Option: Some(user) or None()
fn readFile(path: String) -> String! { ... } // returns a Result: Ok(text) or Err(reason)

T? asks for an Option and T! for a Result. The element — User, String — says what a present or successful value holds, and is treated exactly as an array’s element is:

  • At runtime the container is all that is checked. findUser(1) is User? asks only whether the value is an Option, the same way xs is [String] asks only whether it is an Array. Zirric has no generic types, so there is nothing about the element for a runtime check to look at.
  • Between two written hints the element is compared. Passing a String? where an Int? is declared is reported, as passing [String] where [Int] is declared would be. An element unknown on either side still fits, so a plain Option satisfies a String? and vice versa.
const name: String? = Some("Ada")
const age: Int? = name // reported: age is declared Int?, got String?
const any: Option = name // fine — a bare Option says nothing about what it holds

The suffixes stack (T?! is a Result, the outermost suffix winning) and apply to any type expression, [Int]? and fn() -> Int? included. They must sit directly on the type, with no space between the two; see Syntax § Type Expressions.

Type hints on declarations

See Declarations § Parameters and Type Hints for all positions where type hints can appear.

Type Checking with is

The is operator and switch case is patterns perform runtime type checks. The matching rules depend on the type hint form:

Pattern Matches when
is Type The value’s concrete type is Type
is UnionType The value’s concrete type is a member of the union
is @Attr The value’s type declaration carries the @Attr attribute

is always produces a Bool. In a switch, the matching case’s body is executed.

42 is Int // true
42 is Number // true (Number is union { Int, Float })
42 is String // false
42 is @Numeric // true (Int has @Numeric)

Common Prelude Types

The prelude defines a small set of types and unions available without explicit import.

Number — a union of Int and Float:

union Number {
	Int
	Float
}

Void — represents absence of a meaningful value. The constant void is the single instance. Functions without an explicit return value return void.

Any — the universal type. Every value is an Any. Useful as a type hint when no constraint is needed.

The prelude also defines descriptive attributes (@Deprecated, @Default) and the attributes types opt into behavior with: @Countable and @Iterable for the collection protocols, @Numeric and @Printable for conversion, @Error for what an error can say about itself, and @AnyOption and @AnyResult for unions of your own that ?., !., ?? and !! should read through. See Standard Library § Prelude for the full list.