# typical: a schema compiler that gives you algebraic data types on the wire

> Typical is a data serialization framework where you write a schema of structs and choices and it generates the encoding code for Rust and TypeScript. Its selling point is that it keeps binary compatibility between schema versions while keeping types non-nullable and pattern matching exhaustive, which is the trade the older wire formats give up.

**stepchowfun/typical** — Data interchange with algebraic data types.

- Repository: https://github.com/stepchowfun/typical
- Stars: 767 · Forks: 14
- Language: Rust
- License: NOASSERTION
- Published: 2026-10-21 · Updated: 2026-10-21 · Language: en
- Canonical page: https://hysenlabs.com/projects/stepchowfun-typical

## The proposition: type safety and binary compatibility, which are usually treated as opposites

Typical is a data serialization framework. You define data types in a file called a schema, then Typical generates efficient serialization and deserialization code for various languages. The generated code can be used for marshalling messages between services, storing structured data on disk, and similar jobs. The compact binary encoding supports forward and backward compatibility between different versions of your schema to accommodate evolving requirements.

The comparison the README draws is with Protocol Buffers and Apache Thrift, and it is specific about the difference. Typical has a more modern type system based on algebraic data types, emphasizing a safer programming style with non-nullable types and exhaustive pattern matching. If you have experience with languages that embrace this style, such as Rust and Haskell, you will feel at home.

The stated aim is to offer two features that are conventionally thought to be at odds: uncompromising type safety, and binary compatibility between schema versions.

The design was informed by experience using Protocol Buffers at Google and Apache Thrift at Airbnb, and the README is careful to add that this is not an officially supported product of either company. The author also notes that Typical proposes a new solution to the classic problem of how to safely add or remove fields in record types without breaking compatibility, and that the same concept solves the dual problem of preserving compatibility when adding or removing cases in sum types.

That solution has a name, asymmetric fields, and it is the technical centre of the project.

The supported languages are Rust, TypeScript, and JavaScript via TypeScript.

## The schema language: structs, choices, and field indices that are the real identity

The tutorial walks through building an API for sending emails, and the first step is a schema file, called types.t or any other name you prefer.

```yaml
struct SendEmailRequest {
    to: String = 0
    subject: String = 1
    body: String = 2
}

choice SendEmailResponse {
    success = 0
    error: String = 1
}
```

The two type constructors are the algebraic data type operations. A struct describes messages containing a fixed set of fields, here to, subject and body. A choice describes messages containing exactly one field from a fixed set of possibilities, here success and error. The README explains that structs and choices are called algebraic data types since they can be understood abstractly as multiplication and addition of types, and adds that you do not need to know anything about that to use Typical.

Each field has a name and an integer index, and the index is what matters. The name is just for humans, as only the index is used to identify fields in the binary encoding, and you can freely rename fields without worrying about binary incompatibility as long as you do not change the indices. That is a much stricter and clearer rule than most schema systems give you, where a field number is a number you are supposed to never reuse but about which you get no explanation.

Each field also has a type, and a missing type defaults to a built-in type called Unit, which holds no information and takes zero bytes to encode. That is why the success field in the choice has no type annotation: it is a case that carries nothing.

There is also a formatting step, typical format types.t, which ensures consistent orthography such as indentation and letter case. It is a small thing, but a schema is a source file and a formatter for it is the difference between a codebase where the schema stays readable after a year and one where it does not.

## Generating the code, and wiring it into your build

Generation is a single command that names the outputs:

```sh
typical generate types.t --rust-file rust/types.rs --typescript-dir typescript
```

The tutorial points at the example projects for how to automate it, and summarises the two routes. For Rust you use a Cargo build script that is executed when you invoke cargo build. For TypeScript you use the scripts property of your package.json. It is not necessary to set up an automated build system to use Typical, but the recommendation is to do so for convenience.

That recommendation matters more than it sounds. The whole point of generating code from a schema is that the schema is the source of truth, and the moment a developer edits the generated file instead of the schema you have two sources of truth and a bug that only appears in one build environment. A build script makes the generated file a derived artefact, which is the only way it stays honest.

The generated code is not a generic serialiser interface. The tutorial shows a Rust program building a message of type SendEmailRequestOut and serialising it:

```rust
let message = SendEmailRequestOut {
    to: "typical@example.com".to_owned(),
    subject: "I love Typical!".to_owned(),
    body: "It makes serialization easy and safe.".to_owned(),
};

let mut file = BufWriter::new(File::create(REQUEST_FILE_PATH)?);
message.serialize(&mut file)?;
file.flush()?;
```

And a second program reading it back with the In type:

```rust
let file = BufReader::new(File::open(FILE_PATH)?);
let message = SendEmailRequestIn::deserialize(file)?;

println!("to: {}", message.to);
```

The destination can be anything that implements std::io::Write, so a socket or a buffer works as well as a file. The tutorial notes that this is why the schema type turned into SendEmailRequestOut and SendEmailRequestIn, and defers the explanation to the next section.

## Why the generated types are called In and Out

The In and Out suffixes are the visible consequence of the asymmetric field mechanism, and they are the most unusual thing about the generated API.

Fields are required by default, and the README calls this an unusual design decision, since required fields are often thought to cause trouble for backward and forward compatibility. The point is that in a record type, making a field required is normally a breaking change, because an old sender does not have the field. In Typical, the generated In and Out types are two different views of the same schema, and a field that is required in one may be absent in the other.

So SendEmailRequestOut is what you serialize and SendEmailRequestIn is what you deserialize, and they are not the same type. A field that is required on the wire can appear as optional in the reading type when a newer sender might add it, and the naming makes the direction explicit at every call site rather than hiding it in a default value.

The concept is described in the README as asymmetric fields that can be safely promoted to required and vice versa. Promotion is the interesting direction, because it means a field can start as optional and later become required without breaking the older readers, provided the promotion happens under a rule the encoder enforces.

The same mechanism covers the sum type case. Adding a case to a choice is the operation that breaks tagged unions in other formats, because an old decoder does not know what to do with an unknown tag. With asymmetric fields, the new case is representable and the older side can handle its absence, which is the dual problem the README explicitly says the concept solves.

## The project itself: one author, strict lints, and a mix of Rust and shell

The implementation is a Rust project, and the Cargo.toml shows a serious attitude toward code quality. The lints section denies clippy.all, clippy.default_numeric_fallback, clippy.pedantic and rust.warnings, all with deny level. Denying pedantic clippy lints is a strong statement, because it includes a large set of stylistic and correctness suggestions that most projects leave as warnings or ignore entirely. Denying rust.warnings means a warning fails the build, which catches unused imports and dead code before they accumulate.

The runtime dependencies are few and all for the command line: clap with derive and wrap_help for argument parsing, clap_complete for shell completions, colored for terminal output, textwrap and unicode-segmentation for handling text in the terminal, including wide characters.

The crate uses edition 2024, the current Rust edition, and the package version is 0.16.0. The licence field in Cargo.toml says MIT, and the repository has a LICENSE.md file, while the platform records the licence as a custom one that GitHub cannot classify, so read the file if you are depending on it.

The repository layout shows the pieces of a real toolchain: src/ for the implementation, examples/ with rust/ and typescript/ subdirectories, integration_tests/, test_data/, and benchmarks/. There is an install.sh at the root, a toast.yml, which is the configuration for Toast, the task runner used by the author, and a tagref.yml for tagging.

The release history is regular. v0.16.0 came on 2026-07-09, v0.15.0 on 2026-04-22 and v0.14.0 on 2026-04-20, with the last push on 2026-10-16. The author is Stephan Boyer, and there is a FUNDING.yml and a MAINTAINERS.md at the root, which suggests the project has, or is setting up, a governance structure beyond a single person.

## Where it fits against the alternatives, and what it does not try to be

The comparison with Protocol Buffers and Thrift is not a benchmark result, it is a statement about type systems, and it is the honest way to position the project.

The structural difference is that Protocol Buffers and Thrift give you a schema language whose types are all essentially records with optional and required fields and a scalar type system. A field of message type can be absent, and the language for expressing that is a keyword plus a default. Typical starts from sum types and non-nullable fields, so the generated Rust has no Option for a required field, and a choice becomes an enum that the compiler insists you match exhaustively.

The cost is that you have to think about the schema differently. A field that might be missing is a deliberate, marked decision in Typical, where in a protobuf schema it is the default state of every field. And because the encoding has to remain compatible while the types change shape, you get two generated types per schema type rather than one, which is a slightly heavier mental model.

What Typical explicitly is not is a transport. The README is plain that although the example scenario involves a client talking to a server, Typical has no notion of clients or servers, and it only deals with serialization and deserialization. Networking, encryption and authentication are outside its purview. So you will be building the RPC layer yourself, on reqwest or hyper or whatever, and Typical handles the bytes.

The supported language list is the other hard boundary: Rust, TypeScript, and JavaScript through TypeScript. If one end of your system is written in Go, Java, Python or C++, this project does not help you, and no amount of schema elegance changes that.

## Conclusion

Adopt typical if you are building a service boundary in Rust or TypeScript and want a schema that generates both ends of the wire format, because generating the encoder from one source removes the class of bug where the client and server disagree. Do not adopt it if you need a language that is not Rust or TypeScript, since only those three targets are supported, and expect the generated type names to differ from your schema names, because the In and Out suffixes are there to carry the compatibility distinction. Before you start, decide which fields are safe to make optional, and read the asymmetric fields section, since that is where the compatibility guarantees are actually specified.

## FAQ

### What is Typical and how is it different from Protocol Buffers?

Typical is a data serialization framework where you write a schema of algebraic data types and it generates encoding code. Unlike Protocol Buffers and Thrift, its type system is based on algebraic data types with non-nullable types and exhaustive pattern matching, and it aims to keep binary compatibility between schema versions at the same time.

### What is the difference between the In and Out types Typical generates?

They are two views of the same schema, with the Out type used for serialization and the In type for deserialization. The split exists because a field required in one direction may be absent in the other, which is what makes fields safe to add or remove without breaking compatibility.

### How do I generate code from a Typical schema?

Run typical generate types.t --rust-file rust/types.rs --typescript-dir typescript, and automate it with a Cargo build script for Rust or the scripts property of your package.json for TypeScript. The schema can also be formatted with typical format types.t.

### Which languages does Typical support?

Rust, TypeScript, and JavaScript through TypeScript. The project has no notion of clients or servers and only deals with serialization and deserialization, so networking, encryption and authentication are left to you.

## Sources

- [Issues](https://github.com/stepchowfun/typical/issues)
- [README](https://github.com/stepchowfun/typical/blob/main/README.md)
- [Releases](https://github.com/stepchowfun/typical/releases)
- [stepchowfun/typical on GitHub](https://github.com/stepchowfun/typical)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/stepchowfun-typical
