Coming from Protocol Buffer

If you have used Protocol Buffers (protobuf) before, you will find Skir very familiar. Skir was heavily inspired by protobuf and shares many of its core design principles: efficient binary serialization, schema evolution, and language-agnostic types.

However, Skir was built to address common pain points in the protobuf ecosystem and to provide a superior developer experience. This guide highlights the key differences.

Language differences

Constants

Skir lets you define constants directly in your schema files. You can define complex values (structs, lists, maps, primitives) in your .skir file and they will be compiled into native code constants in your target language.

config.skir
struct Config {
  timeout_ms: int32;
  retries: int32;
  supported_locales: [string];
}

const DEFAULT_CONFIG: Config = {
  timeout_ms = 5000,
  retries = 3,
  supported_locales = ["en-US", "ja-JP", "fr-FR"],
};

The DEFAULT_CONFIG constant is compiled into native code, ensuring your frontend and backend share the exact same configuration values.

Unified enums and oneof

In Protocol Buffers, an enum represents one of multiple stateless options, and a oneof represents one of multiple stateful options. This often leads to awkward patterns when you want a mix of stateless and stateful options, for example:

protobuf
// poker.proto

message PokerAction {
  enum Enum {
    UNKNOWN = 0;
    CHECK = 1;
    BET = 2;
    CALL = 3;
    FOLD = 4;
    RAISE = 5;
  }
  Enum action = 1;
  // Only if action is BET or RAISE
  int32 amount = 2;
}

Skir unifies these two concepts into a specific "Rust-like" enum. Variants can be stateless (like a standard enum) or stateful (holding data), and you can mix them freely.

Skir
enum PokerAction {
  CHECK;           // Stateless variant
  bet: int32;      // Stateful variant (holds the amount)
  FOLD;
  CALL;
  raise: int32;
}

Implicit UNKNOWN variant

The Protobuf Style Guide requires you to manually add an UNSPECIFIED value as the first entry of every enum to handle default values safely.

Skir does this automatically. Every enum in Skir has an implicit UNKNOWN variant (with index 0). This serves as the default value and captures unrecognized variants deserialized from newer schema versions.

Keyed arrays vs maps

Protocol Buffer 3 introduced the map<K, V> type with the goal of preventing developers from having to manually iterate through lists to find items. Such manual iteration is cumbersome and inefficient if multiple lookups have to be performed.

Unfortunately, map comes with a trade-off: in the majority of cases, the key used for indexing is already stored inside the value type.

Protobuf
message User {
  string id = 1;
  string name = 2;
}

message UserRegistry {
  // Redundant: 'id' is stored in the map key AND the User
  map<string, User> users = 1;
}

This forces you to store the ID twice and creates an implicit contract: the code constructing the map must ensure the key matches the ID inside the value.

Skir introduces Keyed Arrays to solve this problem. You define an array and tell the compiler which field of the value acts as the key.

Skir
struct User {
  id: string;
  name: string;
}

struct UserRegistry {
  // Serialized as a list, but indexed by 'id' in generated code
  users: [User|id];
}

On the wire, users is serialized as a plain list of User objects. In the generated code, Skir automatically creates methods to perform O(1) lookups by id. You get the performance of a map with the storage efficiency of a list.

Field numbering

In Skir, fields in a struct are numbered starting from 0, and they must use sequential integers (no gaps allowed).

This contrasts with Protocol Buffers, where field numbers must be greater than or equal to 1, and can be sparse (you can skip numbers). Skir's sequential requirement enables more efficient serialization and deserialization implementations (often just array indexing) compared to the hash map or switch statement approaches often required for sparse field numbers.

API definitions

In Protocol Buffers, service methods are grouped into service blocks. In Skir, methods are defined globally in the schema, and grouping is decided in the application code.

In Protocol Buffers, service methods are identified by their name. In Skir, methods are identified by a numeric ID. This makes it safe to rename methods without breaking compatibility.

Differences in generated code

Although the differences between the protobuf-generated code and the Skir-generated code largely depend on the targeted language, there are some general patterns across languages.

Adding fields to a type

This is a fundamental difference in design philosophy.

With Protocol Buffers, adding a field to a message is guaranteed not to break existing code that constructs instances of that message. If the code isn't updated, the new field simply takes its default value (0, empty string, etc.).

Skir takes the opposite approach: it aims to raise a compile-time error if you add a field to a struct but forget to update the code that constructs it. When you add a field, you usually want to update every instantiation site to populate that field correctly. Skir ensures you don't miss any spot by enforcing strict constructors.

Protobuf
# my_script_with_protobuf.py

# Adding 'email' to User message doesn't break this code.
user = User()
user.id = 123
user.name = "Alice"
Skir
# my_script_with_skir.py

# Static type checkers will raise an error if 'email' is added to User in the
# schema file and this code is not updated.
user = User(
    id=123,
    name="Alice",
)

Note

When deserializing old data that is missing the new field, both Protobuf and Skir behave similarly: the new field is assigned its default value.

Immutability

In most languages, the Skir compiler generates two versions of each struct type: an immutable one and a mutable one.

Immutable types generally help write safer, more predictable, and thread-safe code. However, there are some cases where immutability is overkill and mutable types are simply easier to use.

Skir lets you pick on a case-by-case basis which version you want to use. It creates methods allowing you to easily convert between immutable and mutable, and these functions have smart logic to avoid unnecessary copies.

In contrast, Protocol Buffers typically does not generate immutable types in languages like TypeScript and Python.

Schema evolution: guidelines vs guarantees

Both Protocol Buffers and Skir are designed to support schema evolution, but they take fundamentally different approaches to ensuring compatibility.

Protocol Buffers provides guidelines and best practices for evolving schemas safely. These guidelines tell you which changes are safe (e.g., adding fields, renaming) and which are dangerous (e.g., changing field numbers, incompatible type changes). However, nothing in the toolchain enforces these rules. It is entirely up to developers to remember the guidelines, understand the implications, and avoid making breaking changes.

This manual approach works well in small teams with high discipline, but it becomes error-prone as teams grow or when developers are less familiar with the evolution rules. Breaking changes can slip through code review and cause production issues when old code encounters incompatible new data, or when new code cannot deserialize old persisted records.

Skir takes a different approach: it provides automated enforcement through its built-in snapshot tool. When you run npx skir snapshot, Skir analyzes your current schema against a stored snapshot of the previous version and automatically detects breaking changes. If you accidentally change a field number or make an incompatible type change, Skir will catch it and refuse to proceed.

This shift from guidelines you must remember to follow to automated checks that prevent mistakes provides a much stronger guarantee of compatibility. You can confidently evolve your schema knowing that the tooling will catch any dangerous changes before they reach production. Additionally, when integrated into your CI pipeline or Git pre-commit hooks, the snapshot check ensures that every schema change is validated automatically.

External dependencies

Protocol Buffers does not come with a built-in package manager. To share types across multiple Git repositories, developers traditionally have to rely on git submodule, manual file copying, or external commercial services like buf.

Skir includes a built-in, free package manager that treats GitHub repositories as packages. This allows you to easily share common data structures (like standard currency types or user definitions) across your backend microservices and your frontend applications.

  1. Define dependencies in skir.yml, pointing to any public or private GitHub repository and a tag.
  2. Import the types you need: import User from "@my-org/common-types/user.skir";
  3. Run npx skir gen.

Skir handles downloading the repositories from GitHub, caching them, and resolving imports automatically. You get a full-featured schema registry experience using just your existing source control.

Serialization

Protobuf has two serialization formats: Binary and JSON (Proto3 JSON Mapping).

The Protobuf JSON format is readable (uses field names), but is not safe for schema evolution (renaming fields breaks compatibility).

Skir offers three serialization formats:

  • JSON (Dense): Structs are serialized as arrays ([val1, val2]) rather than objects. It is the default choice offering the best balance between:
    • Space efficiency: although it is not as space efficient as binary, it comes close.
    • Evolution safety: you can rename fields without breaking compatibility.
    • Interoperability and debuggability: being valid JSON, it is easy to inspect and works out of the box with databases (e.g., PostgreSQL JSONB columns) and external tools, unlike binary formats.
  • JSON (Readable): Similar to Protobuf's JSON mapping. Useful for debugging but unsafe for persistence as it relies on field names.
  • Binary: Equivalent to Protobuf binary. A bit more compact and performant than dense JSON.

RPC Services

Protobuf is typically paired with gRPC. While efficient, gRPC requires specific tooling for debugging (like grpcui) and often needs a proxy (gRPC-Web) to be called from a browser.

Skir services run over standard HTTP and are designed to be embedded into your existing application (Express, Flask, Spring Boot, etc.). This makes them naturally compatible with web clients and easy to inspect with standard tools like cURL.

Additionally, every Skir service comes with Skir Studio out of the box. This built-in interactive debugging interface allows you to explore your API and test methods directly in your browser, without needing to install or configure any external tools.