Compare commits
4
Commits
e42d897d41
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b86c9d915e | ||
|
|
dcef2470cb | ||
|
|
88661ec960 | ||
|
|
1d9a7fa4fe |
No files matched your search
@@ -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(); // -> 💥
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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! 💥
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 ...`).
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -30,7 +30,7 @@ class WebSocketClient {
|
||||
}
|
||||
}
|
||||
|
||||
function main_ws() {
|
||||
function main1() {
|
||||
const client = new WebSocketClient();
|
||||
client.send(OpCode.PING, {});
|
||||
}
|
||||
@@ -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.
|
||||
@@ -23,7 +23,7 @@ function navigate<P extends string>(path: P, params: ParseUrlParams<P>) {
|
||||
// ...
|
||||
}
|
||||
|
||||
function main() {
|
||||
function main3() {
|
||||
navigate("/hello", {});
|
||||
navigate("/hello/{name}", { name: "foo" });
|
||||
navigate("/book/{author}/{id}", { author: "foo", id: "bar" });
|
||||
@@ -1,2 +1,3 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["01-problem", "02-runtime-check", "03-builder-pattern", "04-typestate"]
|
||||
@@ -1,5 +1,54 @@
|
||||
# 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/
|
||||
|
||||
Reference in new issue
Block a user