Language reference
This page serves as a reference for the syntax and features of the Skir language.
Records
Records are the top-level data definitions in a Skir schema. There are two types of records: structs and enums.
Structs
Use the keyword struct to define a struct, which is a collection of fields of different types.
The fields of a struct have a name, but during serialization they are actually identified by a number, which can either be set explicitly:
struct Point {
x: int32 = 0;
y: int32 = 1;
label: string = 2;
}or implicitly:
struct Point {
x: int32; // = 0
y: int32; // = 1
label: string; // = 2
}If you're not explicitly specifying the field numbers, you must be careful not to change the order of the fields or else you won't be able to deserialize old values. More information about changes you can make to your schema is available in Schema evolution.
// BAD: you can't reorder the fields and keep implicit numbering
// struct Point {
// label: string;
// x: int32;
// y: int32;
// }
// GOOD
struct Point {
label: string = 2;
// Fine to rename fields
x_coordinate: int32 = 0;
y_coordinate: int32 = 1;
// Fine to add new fields
color: Color = 3;
}Enums
Enums in Skir are similar to enums in Rust. An enum value is one of several possible variants, and each variant can optionally have data associated with it.
// Indicates whether an operation succeeded or failed.
enum OperationStatus {
SUCCESS; // a constant variant
error: string; // a wrapper variant
}In this example, an OperationStatus is one of these 3 things:
- the
SUCCESSconstant - an
errorwith a string value UNKNOWN: a special implicit variant common to all enums
If you need a variant to hold multiple values, wrap them inside a struct:
struct MoveAction {
x: int32;
y: int32;
}
enum BoardGameTurn {
PASS;
move: MoveAction;
}Like the fields of a struct, the variants of an enum have a number, and the numbering can be explicit or implicit.
enum ExplicitNumbering {
// The numbers don't need to be consecutive.
FOO = 10;
bar: string = 2;
}
enum ImplicitNumbering {
// Implicit numbering is 1-based.
// 0 is reserved for the special UNKNOWN variant.
FOO; // = 1
bar: string; // = 2
}The variant numbers are used for identifying the variants in the serialization format (not the variant names). You must be careful not to change the number of a variant, or you won't be able to deserialize old values. For example, if you're using implicit numbering, you must not reorder the variants. More information about changes you can make to your schema is available in Schema evolution.
It is always fine to rename an enum, rename the variants of an enum, or add new variants to an enum.
Nesting records
You can define a record (struct or enum) within the definition of another record. This is simply for namespacing, and it can help make your .skir files more organized.
enum Status {
OK;
struct Error {
message: string;
}
error: Error;
}
struct Foo {
// Note the dot notation to refer to the nested record.
error: Status.Error;
}Inline records
For improved readability and conciseness, Skir allows you to define records (structs or enums) directly within a field's type definition. This inline syntax is a shorthand for explicitly nesting a record definition and then referencing it as a type.
When you use an inline record, the Skir compiler automatically infers the name of the record by converting the snake_case field name into PascalCase.
For example, imagine you are defining a Notification system where each message can have different types of payloads.
// Not using inline records
struct Notification {
metadata: Metadata;
struct Metadata {
sent_at: timestamp;
sender_id: string;
}
payload: Payload;
enum Payload {
APP_LAUNCH;
message: Message;
struct Message {
body: string;
title: string;
}
}
}Using inline records, the same structure can be defined more concisely. The compiler will infer that the type for metadata is Metadata and the type for payload is Payload.
// Using inline records
struct Notification {
metadata: struct {
sent_at: timestamp;
sender_id: string;
}
payload: enum {
APP_LAUNCH;
message: struct {
body: string;
title: string;
}
}
}These two methods of definition are strictly equivalent. The generated code will be identical regardless of whether the record was defined explicitly or inline.
Removed numbers
When removing a field from a struct or a variant from an enum, you must mark the removed number in the record definition using the removed keyword. The syntax is different whether you're using explicit or implicit numbering:
struct ExplicitNumbering {
a: string = 0;
b: string = 1;
f: string = 5;
removed 2..4, 6; // 2..4 is same as 2, 3, 4
}
struct ImplicitNumbering {
a: string;
b: string:
removed;
removed;
removed;
f: string;
removed;
}Stable identifiers
You can assign a numeric stable identifier to a struct or an enum by specifying it in parentheses after the record name:
struct Point(23456) { ... }This identifier is used by the npx skir snapshot command to track record identity across renames and detect breaking changes.
No two types in your Skir project can have the same stable identifier.
Tip
You can use ? as a placeholder for the identifier and run npx skir format. It will replace the question mark with a generated random number. This replacement happens automatically on save if you are using the VSCode extension.
Recursive records
Records can be recursive, meaning a record can contain a field of its own type, either directly or indirectly. This feature is essential for defining recursive data structures such as trees.
struct DecisionNode {
question: string;
yes: DecisionTree;
no: DecisionTree;
}
enum DecisionTree {
result: string;
node: DecisionNode;
}To safeguard against infinite recursion, the generated code in all supported languages has compile-time constraints to prevent an instance of a recursive type from containing itself.
Data types
Primitive types
bool: true or falseint32: a signed 32-bit integerint64: a signed 64-bit integerhash64: an unsigned 64-bit integer; prefer using this for hash codes andint64for numbers which represent an actual countfloat32: a 32-bit floating point number; can be one ofNaN,Infinityor-Infinityfloat64: a 64-bit floating point number; can be one ofNaN,Infinityor-Infinitystring: a Unicode stringbytes: a sequence of bytestimestamp: a specific instant in time represented as an integral number of milliseconds since the Unix epoch, from 100M days before the Unix epoch to 100M days after the Unix epoch
Array type
Wrap the item type inside square brackets to represent an array of items, e.g. [string] or [User].
Keyed arrays
If the items are structs and one of the struct fields can be used to identify every item in the array, you can add the field name next to a pipe character: [Item|key_field].
struct User {
id: int32;
name: string;
}
struct UserRegistry {
users: [User|id];
}Language plugins will generate methods allowing you to perform key lookups in the array using a hash table. For example, in Python:
user = user_registry.users.find(user_id)
if user:
do_something(user)If the item key is nested within another struct, you can chain the field names like so: [Item|a.b.c].
The key type must be a primitive type of an enum type. If it's an enum type, add .kind at the end of the key chain:
enum Weekday {
MONDAY;
TUESDAY;
WEDNESDAY;
THURSDAY;
FRIDAY;
SATURDAY;
SUNDAY;
}
struct WeekdayWorkStatus {
weekday: Weekday;
working: bool;
}
struct Employee {
weekly_schedule: [WeekdayWorkStatus|weekday.kind];
}Optional type
Add a question mark at the end of a non-optional type to make it optional. An other_type? value is either an other_type or null.
Constants
You can define constants of any type with the const keyword. The syntax for representing the value is similar to JSON, with the following differences:
- object keys must not be quoted
- trailing commas are allowed and even encouraged
- strings can be single-quoted or double-quoted
- strings can span multiple lines by escaping new line characters
const PI: float64 = 3.14159;
const LARGE_CIRCLE: Circle = {
center: {
x: 100,
y: 100,
},
radius: 100,
color: {
r: 255,
g: 0,
b: 255,
label: "fuschia",
},
};
const MULTILINE_STRING: string = 'Hello\
world\
!';
const SUPPORTED_LOCALES: [string] = [
"en-GB",
"en-US",
"es-MX",
];
// Use strings for enum constants.
const REST_DAY: Weekday = "SUNDAY";
// Use { kind: ..., value: ... } for enum variants holding a value.
const NOT_IMPLEMENTED_ERROR: OperationStatus = {
kind: "error",
value: "Not implemented",
};All the fields of a struct must be specified, unless you use {| ... |} instead of { ... }, in which case missing fields are set to their default values.
Methods (API)
The method keyword allows you to define the signature of a service method.
struct GetUserProfileRequest {
user_id: int32;
}
struct GetUserProfileResponse {
profile: UserProfile?;
}
method GetUserProfile(GetUserProfileRequest): GetUserProfileResponse = 12345;The request and response can have any type.
Stable identifiers
Every method must have a unique integer identifier (e.g. = 12345) used for RPC routing. This identifier decouples the method's identity from its name, allowing safe renaming and refactoring without breaking compatibility with older clients.
No two methods in your Skir project can have the same stable identifier.
Tip
You can use ? as a placeholder for the identifier and run npx skir format. It will replace the question mark with a generated random number. This replacement happens automatically on save if you are using the VSCode extension.
Inline request/response records
Just as you can define structs and enums inline for fields, Skir supports inline record definitions for RPC methods. This allows you to define the request and response structures directly within the method signature.
When records are defined inline within a method, the Skir compiler automatically generates the record names by appending Request to the method name for the input and Response for the output.
This syntax allows you to define the same method as above more concisely:
// Using inline records
method GetUserProfile(struct {
user_id: int32;
}): struct {
profile: UserProfile?;
} = 12345;Imports
The import statement allows you to import types from another module. You can either specify the names to import, or import the whole module with an alias using the as keyword.
import Point, Circle from "geometry/geometry.skir";
import * as color from "color.skir";
struct Rectangle {
top_left: Point;
bottom_right: Point;
}
struct Disk {
circle: Circle;
fill_color: color.Color; // the type is defined in the "color.skir" module
}The path is always relative to the root of the Skir source directory.
Doc comments
Doc comments are designated by three forward slashes (///) and are used to provide high-level documentation for records, fields, variants, methods and constants. Unlike regular comments (// or /*), which are ignored by the compiler, doc comments are processed as part of your schema definition.
Referencing symbols
Doc comments can contain references to other symbols within your schema by enclosing them in square brackets. If a symbol referenced in square brackets is missing or misspelled, the Skir compiler will trigger a compilation error. This ensures that your documentation never becomes stale or refers to fields that no longer exist.
struct Account {
/// Same as [User.email]
email: string;
/// True if the [email] has been confirmed via a verification link.
is_verified: bool;
created_at: timestamp;
}Integration with code generators
One of the primary advantages of doc comments is that they are copied directly into the generated code. Developers using IDEs like VSCode or IntelliJ will see your documentation in hover information, code completion, and inlay hints.
RPC visibility and security
When documenting types used as a request or response for an RPC method, be aware that these comments may be visible to any user or client with access to that interface.
For this reason, it is critical not to include business-confidential information, internal server paths, or sensitive security logic in doc comments for types that will be exposed via public-facing services.