Compare commits

..
7 Commits
Author SHA1 Message Date
zekro b86c9d915e add more documentation 2026-07-27 11:32:15 +02:00
zekro dcef2470cb add documentation 2026-07-27 09:22:30 +02:00
zekro 88661ec960 update readme 2026-07-20 09:58:50 +02:00
zekro 1d9a7fa4fe add typestate typescript example 2026-07-20 09:54:35 +02:00
zekro e42d897d41 update examples and add documentation 2026-07-17 11:30:08 +02:00
zekro 9e4e019660 add more typescript examples 2026-07-17 10:52:41 +02:00
zekro 2bc148342d Dwip 2026-07-17 10:28:20 +02:00
20 changed files with 408 additions and 53 deletions

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
# The Problem
We have a `CredentialManager` implementation which can be in two states: **locked** or **unlocked**.
When the manager is locked, it can be unlocked with the `unlock(...)` method. Now unlocked, the `set(...)` and `get(...)` methods can be performed on the manager. Also, it can be locked again using the `lock()` method.
```rust
let mut pw = CredentialManager::new();
pw.unlock("pssst");
pw.set("foo", "bar");
let val = pw.get("foo");
pw.lock();
```
But with the current implementation, on a locked manager, the `get` and `set` could be performed which would result in erroneous results.
```rust
let mut pw = CredentialManager::new();
pw.set("foo", "bar"); // -> 💥
```
Also, nothing prevents us from unlocking an already unlocked manager as well as locking an already locked manager.
```rust
let mut pw = CredentialManager::new();
pw.unlock("pssst");
pw.unlock("pssst"); // -> 💥
let mut pw = CredentialManager::new();
pw.lock(); // -> 💥
```
+18
View File
@@ -0,0 +1,18 @@
# Solution 1: Runtime Checks
One solution to the problem would be to simply check the locked state and restrict methods to be only callable in the corrent locked state.
```rust
let mut pw = CredentialManager::new();
pw.set("foo", "bar")?; // this would bail with an error result
pw.unlock("pssst")?;
pw.unlock("pssst")?; // this would bail with an error result
pw.set("foo", "bar")?;
let val = pw.get("foo")?;
pw.lock()?;
pw.lock()?; // this would bail with an error result
```
## Caveats
Performing runtime checks on the state requires returning `Result` and also requires the check and handling of erroneous results on every call to the manager.
+1 -1
View File
@@ -10,7 +10,7 @@ struct CredentialManager {
impl CredentialManager {
pub fn new() -> CredentialManager {
CredentialManager {
locked: false,
locked: true,
entries: HashMap::new(),
}
}
+55
View File
@@ -0,0 +1,55 @@
# Solution 2: "Builder Pattern"
A better solution is to use two different structs for each state with their separate implementations. That ensured on a compiler level that the manager can not be misused in either state it is.
```rust
let pw = LockedCredentialManager::new();
pw.set("foo", "bar");
```
This produces the following compiler error:
```
error[E0599]: no method named `set` found for struct `LockedCredentialManager` in the current scope
--> 03-builder-pattern\src\main.rs:45:8
|
5 | struct LockedCredentialManager {
| ------------------------------ method `set` not found for this struct
...
45 | pw.set("foo", "bar");
| ^^^ method not found in `LockedCredentialManager`
```
And because we pass the struct by reference in the `unlock` and `lock` methods, resulting from Rust's [onwership memory model](https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html), the previous object can not be used after the transformation. And because this is checked at compile time, it makes the double locking and unlocking a non-issue in production. Let's see what the following code results in.
```rust
let pw = LockedCredentialManager::new();
let mut pw = pw.unlock("pssst");
pw.lock();
pw.lock();
```
This code results in the following compiler error:
```
error[E0382]: use of moved value: `pw`
--> 03-builder-pattern\src\main.rs:49:5
|
45 | let mut pw = pw.unlock("pssst");
| ------ move occurs because `pw` has type `UnlockedCredentialManager`, which does not implement the `Copy` trait
...
48 | pw.lock();
| ------ `pw` moved due to this method call
49 | pw.lock();
| ^^ value used here after move
|
note: `UnlockedCredentialManager::lock` takes ownership of the receiver `self`, which moves `pw`
--> 03-builder-pattern\src\main.rs:36:17
|
36 | pub fn lock(self) -> LockedCredentialManager {
| ^^^^
```
## Caveats
This solution requires two structs for the same functionality which may hurt readability and conciseness of the code. Also, it requires to move data around between struct instances when transitioning between states which introduces unnecessary overhead and complexity.
+5 -5
View File
@@ -41,9 +41,9 @@ impl UnlockedCredentialManager {
}
fn main() {
let pw = LockedCredentialManager::new();
let mut pw = pw.unlock("pssst");
pw.set("foo", "bar");
let val = pw.get("foo");
pw.lock();
let pw_locked = LockedCredentialManager::new();
let mut pw_unlocked = pw_locked.unlock("pssst");
pw_unlocked.set("foo", "bar");
let val = pw_unlocked.get("foo");
let pw_locked = pw_unlocked.lock();
}
+7
View File
@@ -0,0 +1,7 @@
# Solution 3: Typestate Pattern using Marker Types
A yet better solution is to utilize the functionality of Rust to be able to do different implementations on a struct depending on type parameters.
We simply introduce two empty marker types `Locked` and `Unlocked`. Then, we pass the marker type as type parameter to the `CredentialManager` struct. Because Rust enforces the usage of type parameters, we need the type parameter to appear in a field for Rust to accept it. We could use `state: Locked` directly, since `Locked` is zero-sized and this compiles fine and costs nothing at runtime. But [`PhantomData<T>`](https://doc.rust-lang.org/nightly/nomicon/phantom-data.html) is preferred by convention because it decouples the type-level marker from needing an actual constructible value of `T`, doesn't silently grow the struct's size if `T` is later changed to something non-zero-sized, and lets you control drop-check/variance/auto-trait behavior independently.
So in this solution, we get the exact same behavior as with the last solution, but with absolutely no extra cost at runtime, which perfectly utilizes the [zero cost abstraction](https://doc.rust-lang.org/beta/embedded-book/static-guarantees/zero-cost-abstractions.html) principle of Rust.
+6 -6
View File
@@ -11,7 +11,7 @@ struct CredentialManager<State = Locked> {
}
impl CredentialManager {
pub fn new() -> CredentialManager<Locked> {
pub fn new() -> CredentialManager {
CredentialManager {
entries: HashMap::new(),
state: std::marker::PhantomData,
@@ -46,9 +46,9 @@ impl CredentialManager<Unlocked> {
}
fn main() {
let pw = CredentialManager::new();
let mut pw = pw.unlock("pssst");
pw.set("foo", "bar");
let val = pw.get("foo");
pw.lock();
let pw_locked = CredentialManager::new();
let mut pw_unlocked = pw_locked.unlock("pssst");
pw_unlocked.set("foo", "bar");
let val = pw_unlocked.get("foo");
let pw_locked = pw_unlocked.lock();
}
+23
View File
@@ -0,0 +1,23 @@
# Typestate Pattern in Go: Separate Structs
In Go, we can implement the typestate pattern similarly to the Rust "Builder Pattern" solution by using separate structs for each state.
```go
pwLocked := NewCredentialManager()
pwUnlocked := pwLocked.Unlock("pssst")
pwUnlocked.Set("foo", "bar")
_, _ = pwUnlocked.Get("foo")
pwUnlocked.Lock()
```
By returning a different struct type from `Unlock` and `Lock`, we can control which methods are available on the returned object.
## Caveats
Unlike Rust, Go does not have an ownership model. This means that the original object (e.g., `pwLocked`) remains valid and usable even after it has been "moved" to a new state. This can lead to logic errors if the old object is reused.
```go
pwLocked := NewCredentialManager()
pwUnlocked := pwLocked.Unlock("pssst")
pwLocked.Unlock("pssst") // Still possible! 💥
```
+5 -7
View File
@@ -35,11 +35,9 @@ func (m UnlockedCredentialManager) Lock() LockedCredentialManager {
}
func main() {
pw := NewCredentialManager()
unlocked := pw.Unlock("pssst")
unlocked.Set("foo", "bar")
_, _ = unlocked.Get("foo")
unlocked.Lock()
pwLocked := NewCredentialManager()
pwUnlocked := pwLocked.Unlock("pssst")
pwUnlocked.Set("foo", "bar")
_, _ = pwUnlocked.Get("foo")
pwUnlocked.Lock()
}
+23
View File
@@ -0,0 +1,23 @@
# Typestate Pattern in Go: Interfaces
Another way to implement the typestate pattern in Go is by using interfaces to restrict the methods available on an object based on its current state.
```go
pwLocked := NewCredentialManager()
pwUnlocked, _ := pwLocked.Unlock("pssst")
pwUnlocked.Set("foo", "bar")
_, _ = pwUnlocked.Get("foo")
pwUnlocked.Lock()
```
In this implementation, both `LockedCredentialManager` and `UnlockedCredentialManager` are interfaces that are satisfied by a single internal `credentialManager` struct. The transitions return the same object but cast to the appropriate interface.
## Caveats
Similar to the struct-based approach, interfaces in Go do not prevent the use of the original interface variable after a transition. Additionally, because the underlying type is the same, it's possible to cast the interface back to the original type or another interface if the underlying type is known, bypassing the state protections. It also still requires internal runtime checks to ensure consistency if the object is shared.
```go
pwLocked := NewCredentialManager()
pwUnlocked, _ := pwLocked.Unlock("pssst")
pwUnlocked.(LockedCredentialManager).Unlock("psst") // This is possible and would cause a runtime error
```
+8
View File
@@ -52,3 +52,11 @@ func (t *credentialManager) Get(key string) (string, error) {
}
panic("todo")
}
func main() {
pwLocked := NewCredentialManager()
pwUnlocked, _ := pwLocked.Unlock("pssst")
pwUnlocked.Set("foo", "bar")
_, _ = pwUnlocked.Get("foo")
pwUnlocked.Lock()
}
-34
View File
@@ -1,34 +0,0 @@
enum OpCode {
PING,
HELLO,
MOVE,
}
type HelloEvent = {
name: string;
};
type MoveEvent = {
x: number;
y: number;
};
type OpArgsMapping = {
[OpCode.PING]: null;
[OpCode.HELLO]: HelloEvent;
[OpCode.MOVE]: MoveEvent;
};
class WebSocketClient {
public op<TOp extends OpCode, TArgs extends OpArgsMapping[TOp]>(
op: TOp,
args: TArgs,
) {
// ...
}
}
function main() {
const client = new WebSocketClient();
client.op(OpCode.HELLO, { name: "foo" });
}
+27
View File
@@ -0,0 +1,27 @@
# Typestate Pattern in TypeScript
TypeScript allows for a very elegant implementation of the typestate pattern using `this` type parameters. This enables us to restrict method calls based on the generic state of the class instance.
```typescript
let pw_locked = new CredentialManager<"locked">();
let pw_unlocked = pw_locked.unlock("psst");
pw_unlocked.set("foo", "bar");
let val = pw_unlocked.get("foo");
pw_locked = pw_unlocked.lock();
```
By using `this: CredentialManager<"unlocked">` in the method signature, we tell TypeScript that the method can only be called when the instance is of that specific type.
```typescript
let pw_locked = new CredentialManager<"locked">();
pw_locked.set("foo", "bar"); // -> 💥 Compiler Error
```
This produces a compiler error similar to:
```
Property 'set' does not exist on type 'CredentialManager<"locked">'.
```
## Caveats
Like Go, TypeScript does not have an ownership model, so the original variable remains usable after a transition. Furthermore, TypeScript's type system is structural and can be bypassed with type assertions (`as any` or `as unknown as ...`).
+30
View File
@@ -0,0 +1,30 @@
type State = "locked" | "unlocked";
class CredentialManager<S extends State> {
private readonly state?: S;
unlock(this: CredentialManager<"locked">, master_key: string) {
return this as unknown as CredentialManager<"unlocked">;
}
set(this: CredentialManager<"unlocked">, key: string, value: any) {
// ...
}
get(this: CredentialManager<"unlocked">, key: string): string | undefined {
// ...
return undefined;
}
lock(this: CredentialManager<"unlocked">): CredentialManager<"locked"> {
return this as unknown as CredentialManager<"locked">;
}
}
function main2() {
let pw_locked = new CredentialManager<"locked">();
let pw_unlocked = pw_locked.unlock("psst");
pw_unlocked.set("foo", "bar");
let val = pw_unlocked.get("foo");
pw_locked = pw_unlocked.lock();
}
+27
View File
@@ -0,0 +1,27 @@
# Type Mapping in TypeScript
This example demonstrates how to use TypeScript's mapped types and generics to create a type-safe mapping between an operation code (enum) and the expected arguments for that operation.
```typescript
type OpArgsMapping = {
[OpCode.PING]: PingEvent;
[OpCode.HELLO]: HelloEvent;
[OpCode.MOVE]: MoveEvent;
};
class WebSocketClient {
public send<TOp extends OpCode, TArgs extends OpArgsMapping[TOp]>(
op: TOp,
args: TArgs,
) {
// ...
}
}
```
When calling `send`, TypeScript automatically infers the correct type for `args` based on the value passed for `op`.
```typescript
client.send(OpCode.MOVE, { x: 10, y: 20 }); // Correct
client.send(OpCode.MOVE, { name: "foo" }); // -> 💥 Compiler Error
```
+36
View File
@@ -0,0 +1,36 @@
enum OpCode {
PING,
HELLO,
MOVE,
}
type PingEvent = Record<string, never>;
type HelloEvent = {
name: string;
};
type MoveEvent = {
x: number;
y: number;
};
type OpArgsMapping = {
[OpCode.PING]: PingEvent;
[OpCode.HELLO]: HelloEvent;
[OpCode.MOVE]: MoveEvent;
};
class WebSocketClient {
public send<TOp extends OpCode, TArgs extends OpArgsMapping[TOp]>(
op: TOp,
args: TArgs,
) {
// ...
}
}
function main1() {
const client = new WebSocketClient();
client.send(OpCode.PING, {});
}
+18
View File
@@ -0,0 +1,18 @@
# Type Programming in TypeScript
This example showcases advanced type-level programming in TypeScript using template literal types and recursive type definitions.
We can define a type that parses a string literal at compile-time to extract placeholders and construct a corresponding object type.
```typescript
type ParseUrlParams<Url> = ...;
function navigate<P extends string>(path: P, params: ParseUrlParams<P>) {
// ...
}
navigate("/book/{author}/{id}", { author: "foo", id: "bar" }); // Correct
navigate("/book/{author}/{id}", { author: "foo" }); // -> 💥 Compiler Error: missing 'id'
```
This allows for extremely type-safe APIs, where the parameters required for a function are derived directly from a string template, ensuring that developers provide all necessary data.
+30
View File
@@ -0,0 +1,30 @@
// This recursively matches path segments and parameters in segments which
// are surrounded by curly braces (i.e. `{foo}`) in these segments.
// The results are joined to a superset of objects with their params
// via the `&`.
// In example, the path /{foo}/{bar} results in the type
// `{ foo: string } & { bar: string }`
type ParseUrlParamsRaw<Url> = Url extends `${infer Start}/${infer Rest}`
? ParseUrlParamsRaw<Start> & ParseUrlParamsRaw<Rest>
: Url extends `{${infer Param}}`
? { [P in Param]: string }
: {};
// ParseUrlParamsRaw allows passing objects with any keys (i.e. `{name: "foo"}`) even though
// an empty object is expected (`{}`). This type constraint enforces this by checking if
// the result of ParseUrlParamsRaw<Url> is `{}`. `keyof {}` evaluates to `never`, so
// when `{}` is evaluated an empty Record is expected, otherwise the result of
// ParseUrlParamsRaw is expected.
type ParseUrlParams<Url> = keyof ParseUrlParamsRaw<Url> extends never
? Record<string, never>
: ParseUrlParamsRaw<Url>;
function navigate<P extends string>(path: P, params: ParseUrlParams<P>) {
// ...
}
function main3() {
navigate("/hello", {});
navigate("/hello/{name}", { name: "foo" });
navigate("/book/{author}/{id}", { author: "foo", id: "bar" });
}
+1
View File
@@ -1,2 +1,3 @@
[workspace]
resolver = "3"
members = ["01-problem", "02-runtime-check", "03-builder-pattern", "04-typestate"]
+57
View File
@@ -0,0 +1,57 @@
# Type Level Programming
This repository contains a collection of examples demonstrating various type-level programming patterns in Rust, Go, and TypeScript.
The goal of these examples is to show how to leverage the type system of a language to enforce invariants at compile-time, reducing the need for runtime checks and making the code more robust and self-documenting.
## Repository Structure
The examples are organized by language and complexity.
### Rust
The Rust examples demonstrate a progression of state management for a `CredentialManager`:
- **[01-problem](./01-problem)**: The initial problem where state is not managed, allowing operations in any order.
- **[02-runtime-check](./02-runtime-check)**: Adding runtime checks (e.g., `bool` flags) to ensure operations are performed in the correct state.
- **[03-builder-pattern](./03-builder-pattern)**: Using separate structs to represent different states, ensuring state transitions are enforced at compile-time.
- **[04-typestate](./04-typestate)**: A classic implementation of the Typestate pattern using generics and `PhantomData` to track state.
### Go
- **[05-typestate-go-structs](./05-typestate-go-structs)**: Implementing state transitions in Go using separate structs for each state.
- **[06-typestate-go-interfaces](./06-typestate-go-interfaces)**: Using interfaces to restrict the available methods based on the current state of the object.
### TypeScript
- **[07-typestate-ts](./07-typestate-ts)**: Implementing the Typestate pattern in TypeScript using `this` type parameters to restrict method calls.
- **[08-type-mapping](./08-type-mapping)**: Mapping enum values (like opcodes) to specific argument types for type-safe messaging.
- **[09-type-programming](./09-type-programming)**: Advanced recursive type parsing to extract parameters from URL string literals at compile-time.
## Getting Started
### Rust
You can run the Rust examples using Cargo from the root directory:
```bash
# Example: Running the typestate example
cargo run -p 04-typestate
```
### Go
The Go examples can be run using the Go CLI:
```bash
go run ./05-typestate-go-structs
```
### TypeScript
The TypeScript examples are best viewed in an IDE (like VS Code) to see the type errors and completions. You can also run them using `ts-node`:
```bash
npx ts-node 07-typestate-ts/main.ts
```
## Sources
- https://www.emoses.org/posts/resolver-resolved-pattern/
- https://farazdagi.com/posts/2024-04-07-typestate-pattern/
- https://youtu.be/_ccDqRTx-JU
- https://type-level-typescript.com/