Rust

This guide explains how to use Skir in a Rust project. Targets Rust edition 2021 and higher.

Set up

In your skir.yml file, add the following snippet under generators:

yaml
- mod: skir-rust-gen
  outDir: ./src/skirout
  config: {}

The generated Rust code has a runtime dependency on the skir-client crate. Add it to your Cargo.toml with:

toml
skir-client = "0.1"

In your src/lib.rs (or src/main.rs), expose the generated module:

rust
pub mod skirout;

Then create src/skirout.rs with the following content (the generator will populate the base subdirectory):

rust
// The skirout module is generated by `npx skir gen`. Do not edit by hand.
pub mod base;

For more information, see this Rust project example.

Generated code guide

The examples below are for the code generated from this .skir file.

Referring to generated symbols

rust
// Import generated types from the module generated from "user.skir".
use crate::skirout::base::user::{
    tarzan_const, SubscriptionStatus, SubscriptionStatus_Trial, User, UserRegistry, User_Pet,
};

// Now you can use: User, SubscriptionStatus, tarzan_const(), etc.

Struct types

Skir generates a plain Rust struct for every struct in the .skir file. All fields are public. Every struct implements Default, Clone, PartialEq, and Debug.

rust
// Construct a User by filling in each field directly.
let john = User {
    user_id: 42,
    name: "John Doe".to_string(),
    quote: "Coffee is just a socially acceptable form of rage.".to_string(),
    pets: vec![User_Pet {
        name: "Dumbo".to_string(),
        height_in_meters: 1.0,
        picture: "🐘".to_string(),
        _unrecognized: None,
    }],
    subscription_status: SubscriptionStatus::Free,
    _unrecognized: None, // Present in every struct; always set to None
};

println!("{}", john.name); // John Doe

// User::default() returns a User with every field set to its default value.
println!("{}", User::default().name);    // (empty string)
println!("{}", User::default().user_id); // 0

// Struct update syntax: specify some fields and use defaults for the rest.
let jane = User {
    user_id: 43,
    name: "Jane Doe".to_string(),
    ..User::default()
};

println!("{}", jane.quote);      // (empty string)
println!("{}", jane.pets.len()); // 0

Creating modified copies

rust
// Option 1: clone, then mutate.
let mut evil_john = john.clone();
evil_john.name = "Evil John".to_string();
evil_john.quote = "I solemnly swear I am up to no good.".to_string();

println!("{}", evil_john.name);    // Evil John
println!("{}", evil_john.user_id); // 42 (copied from john)
println!("{}", john.name);         // John Doe (john is unchanged)

// Option 2: use struct update syntax to copy fields from an existing instance.
let evil_john_2 = User {
    name: "Evil John".to_string(),
    quote: "I solemnly swear I am up to no good.".to_string(),
    ..john.clone()
};

println!("{}", evil_john_2.name);    // Evil John
println!("{}", evil_john_2.user_id); // 42 (copied from john)
println!("{}", john.name);           // John Doe (john is unchanged)

Enum types

Skir generates a Rust enum for every enum in the .skir file. The Unknown variant is added automatically and is the default.

The definition of the SubscriptionStatus enum in the .skir file is:

Skir
enum SubscriptionStatus {
  FREE;
  trial: Trial;
  PREMIUM;
}

Making enum values

rust
let _ = [
    // Unknown is the default and is present in all Skir enums.
    SubscriptionStatus::Unknown(None),
    SubscriptionStatus::default(), // Same as ::Unknown(None)
    SubscriptionStatus::Free,
    SubscriptionStatus::Premium,
    // Wrapper variants carry a value inside a Box.
    SubscriptionStatus::Trial(Box::new(SubscriptionStatus_Trial {
        start_time: std::time::SystemTime::now(),
        _unrecognized: None,
    })),
];

Conditions on enums

rust
// Direct match on enum variants:
let get_info_text = |status: &SubscriptionStatus| -> String {
    match status {
        SubscriptionStatus::Free => "Free user".to_string(),
        SubscriptionStatus::Premium => "Premium user".to_string(),
        SubscriptionStatus::Trial(t) => {
            format!("On trial since {:?}", t.start_time)
        }
        SubscriptionStatus::Unknown(_) => "Unknown subscription status".to_string(),
    }
};

println!("{}", get_info_text(&john.subscription_status)); // Free user

Serialization

User::serializer() returns a Serializer<User> which can serialise and deserialise instances of User.

rust
use skir_client::{JsonFlavor, Serializer, UnrecognizedValues};

let serializer = User::serializer();

// Serialize to dense JSON (field-number-based; the default mode).
// Use this when you plan to deserialize the value later. Because field
// names are not included, renaming a field remains backward-compatible.
let john_dense_json = serializer.to_json(&john, JsonFlavor::Dense);
println!("{}", john_dense_json);
// [42,"John Doe",...]

// Serialize to readable (name-based, indented) JSON.
// Use this mainly for debugging.
println!("{}", serializer.to_json(&john, JsonFlavor::Readable));
// {
//   "user_id": 42,
//   "name": "John Doe",
//   "quote": "Coffee is just a socially acceptable form of rage.",
//   "pets": [
//     {
//       "name": "Dumbo",
//       "height_in_meters": 1.0,
//       "picture": "🐘"
//     }
//   ],
//   "subscription_status": "FREE"
// }

// The dense JSON flavor is the flavor you should pick if you intend to
// deserialize the value in the future. Skir allows fields to be renamed, and
// because fields names are not part of the dense JSON, renaming a field does
// not prevent you from deserializing the value.
// You should pick the readable flavor mostly for debugging purposes.

// Deserialize from JSON (both dense and readable formats are accepted).
let reserialized_john = serializer
    .from_json(&john_dense_json, UnrecognizedValues::Drop)
    .unwrap();
assert_eq!(reserialized_john, john);

// Serialize to binary format (more compact than JSON; useful when
// performance matters, though the difference is rarely significant).
let john_bytes = serializer.to_bytes(&john);
let from_bytes = serializer
    .from_bytes(&john_bytes, UnrecognizedValues::Drop)
    .unwrap();
assert_eq!(from_bytes, john);

Primitive serializers

rust
println!("{}", Serializer::bool().to_json(&true, JsonFlavor::Dense));
// 1
println!("{}", Serializer::int32().to_json(&3_i32, JsonFlavor::Dense));
// 3
println!(
    "{}",
    Serializer::int64().to_json(&9_223_372_036_854_775_807_i64, JsonFlavor::Dense)
);
// "9223372036854775807"
println!("{}", Serializer::float32().to_json(&1.5_f32, JsonFlavor::Dense));
// 1.5
println!("{}", Serializer::float64().to_json(&1.5_f64, JsonFlavor::Dense));
// 1.5
println!(
    "{}",
    Serializer::string().to_json(&"Foo".to_string(), JsonFlavor::Dense)
);
// "Foo"

Composite serializers

rust
// Optional serializer:
println!(
    "{}",
    Serializer::optional(Serializer::string())
        .to_json(&Some("foo".to_string()), JsonFlavor::Dense)
);
// "foo"

println!(
    "{}",
    Serializer::optional(Serializer::string()).to_json(&None::<String>, JsonFlavor::Dense)
);
// null

// Array serializer:
println!(
    "{}",
    Serializer::array(Serializer::bool()).to_json(&vec![true, false], JsonFlavor::Dense)
);
// [1,0]

Constants

Constants declared with const in the .skir file are available as functions in the generated Rust code.

rust
println!(
    "{}",
    User::serializer().to_json(tarzan_const(), JsonFlavor::Readable)
);
// {
//   "user_id": 123,
//   "name": "Tarzan",
//   "quote": "AAAAaAaAaAyAAAAaAaAaAyAAAAaAaAaA",
//   "pets": [
//     {
//       "name": "Cheeta",
//       "height_in_meters": 1.67,
//       "picture": "🐒"
//     }
//   ],
//   "subscription_status": {
//     "kind": "trial",
//     "value": {
//       "start_time": {
//         "unix_millis": 1743592409000,
//         "formatted": "2025-04-02T11:13:29.000Z"
//       }
//     }
//   }
// }

Keyed lists

rust
// In the .skir file:
//   struct UserRegistry {
//     users: [User|user_id];
//   }
// The '|user_id' part tells Skir to generate a keyed array with O(1)
// lookup by user_id.

let user_registry = UserRegistry {
    users: vec![john.clone(), jane.clone(), evil_john.clone()].into(),
    _unrecognized: None,
};

// find_by_key returns the first element whose user_id matches.
// The first call is O(n) to build the index; subsequent calls are O(1).
let found = user_registry.users.find_by_key(43);
println!("{}", found.is_some());         // true
println!("{}", found.unwrap() == &jane); // true

// If multiple elements share the same key, the first one wins.
let found2 = user_registry.users.find_by_key(42);
println!("{}", found2.unwrap() == &john); // true

let not_found = user_registry.users.find_by_key(999_i32);
println!("{}", not_found.is_none()); // true

// find_by_key_or_default() returns a reference to the default (zero) value
// when the key is not present, instead of returning None.
let not_found_or_default = user_registry.users.find_by_key_or_default(999_i32);
println!("{}", not_found_or_default.pets.len()); // 0

Skir services

Starting a Skir service on an HTTP server

Full example here.

Sending RPCs to a Skir service

Full example here.

Reflection

Reflection allows you to inspect a Skir type at runtime.

rust
use skir_client::TypeDescriptor;

let td = User::serializer().type_descriptor();
if let TypeDescriptor::Struct(sd) = td {
    let names: Vec<&str> = sd.fields().iter().map(|f| f.name()).collect();
    println!("{:?}", names);
    // ["user_id", "name", "quote", "pets", "subscription_status"]
}

// A TypeDescriptor can be serialized to JSON and deserialized later.
let td2 = TypeDescriptor::parse_from_json(
    &User::serializer().type_descriptor().as_json(),
)
.unwrap();
if let TypeDescriptor::Struct(sd2) = td2 {
    println!("{}", sd2.fields().len()); // 5
}