Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,13 @@ cd web && npm test

## Type generation

TypeScript types are generated from Rust structs via ts-rs. After changing any struct with `#[ts(export)]` in `src/models.rs`:
TypeScript types are generated from Rust structs via [typeshare](https://github.com/1Password/typeshare). After changing any struct with `#[typeshare]` in `src/models.rs`:

```bash
scripts/generate-types.sh
```

This writes individual `.ts` files to `web/src/types/generated/`. The barrel `index.ts` in that directory re-exports them — update it if you add or remove types.
This runs the `typeshare` CLI and writes a single `types.ts` file to `web/src/types/generated/`. The barrel `index.ts` in that directory re-exports them — update it if you add or remove types.

To verify types are up to date and the frontend compiles against them:

Expand All @@ -54,7 +54,7 @@ scripts/check-types.sh

- **Library crate + binaries**: `src/lib.rs` exports modules (`error`, `handlers`, `models`). Binaries: `todo-api` (server, postgres), `todo-migrate` (migration runner, postgres), `handler` (Vercel function, sqlite).
- **No ORM**: handlers use `sqlx::query_as!`/`sqlx::query!` macros with raw SQL for compile-time verified queries. Keep it that way.
- **Separate DB and API types**: `TodoRow` (with `FromRow`) is the database row; `Todo` (with `Serialize + TS`) is the API response. Convert with `From<TodoRow>`. Don't merge them. `TodoRow` and `From<TodoRow>` are cfg-gated for postgres vs sqlite.
- **Separate DB and API types**: `TodoRow` (with `FromRow`) is the database row; `Todo` (with `Serialize` + `#[typeshare]`) is the API response. Convert with `From<TodoRow>`. Don't merge them. `TodoRow` and `From<TodoRow>` are cfg-gated for postgres vs sqlite.
- **Frontend state**: TanStack Query manages server state. Mutations invalidate the `['todos']` query key. No client-side state management library.

## Conventions
Expand All @@ -78,7 +78,7 @@ scripts/check-types.sh
1. Add handler function in `src/handlers.rs`
2. Add route in `src/main.rs`
3. Add fetch function in `web/src/api.ts`
4. If new request/response types are needed, add to `src/models.rs` with `#[ts(export)]`, regenerate, and update `web/src/types/generated/index.ts`
4. If new request/response types are needed, add to `src/models.rs` with `#[typeshare]`, regenerate, and update `web/src/types/generated/index.ts`
5. Re-run `cargo sqlx prepare` if the handler has SQL queries (see below)

## Testing
Expand Down Expand Up @@ -120,8 +120,8 @@ Commit the updated `.sqlx/` directory after preparing.

## Gotchas

- ts-rs maps Rust `i64` to TypeScript `bigint` by default. Use `#[ts(type = "number")]` for JSON-serialized integer fields.
- ts-rs maps `Option<T>` to `T | null`, not `T | undefined`. The frontend `api.ts` converts `undefined` → `null` when building request bodies.
- typeshare does not support `i64`/`u64` natively (JSON precision concerns). Use `i32` for count fields, or `#[typeshare(serialized_as = "String")]` for large integers.
- typeshare maps `Option<T>` to optional fields (`field?: T`), not `T | null`. The frontend `api.ts` converts `undefined` → `null` when building request bodies.
- The API CORS config reads the `CORS_ORIGIN` env var (defaults to `http://localhost:5173`). Set it in `.env` if the frontend runs on a different origin.

## Feature flags
Expand Down
53 changes: 16 additions & 37 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
ts-rs = { version = "10", features = ["serde-compat", "uuid-impl", "chrono-impl"] }
typeshare = "1"
tower-http = { version = "0.6", features = ["cors", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
Expand Down
24 changes: 12 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@

Full-stack TodoMVC with a Rust API and a React frontend, connected by generated TypeScript types.

Demonstrates the pattern: define data types once in Rust, derive TypeScript interfaces with [ts-rs](https://github.com/Aleph-Alpha/ts-rs), and use them in the frontend — no manual type synchronization.
Demonstrates the pattern: define data types once in Rust, generate TypeScript interfaces with [typeshare](https://github.com/1Password/typeshare), and use them in the frontend — no manual type synchronization.

## Stack

| Layer | Tech |
|-------|------|
| API | [Axum](https://github.com/tokio-rs/axum) + [SQLx](https://github.com/launchbadge/sqlx) (direct queries, no ORM) |
| Database | PostgreSQL |
| Type bridge | [ts-rs](https://github.com/Aleph-Alpha/ts-rs) — Rust structs → TypeScript interfaces |
| Type bridge | [typeshare](https://github.com/1Password/typeshare) — Rust structs → TypeScript interfaces |
| Frontend | React 19, Vite, [TanStack Query](https://tanstack.com/query) |
| Migrations | [SQLx](https://github.com/launchbadge/sqlx) built-in (`sqlx::migrate!`) |

Expand All @@ -20,7 +20,7 @@ Demonstrates the pattern: define data types once in Rust, derive TypeScript inte
src/
main.rs # Axum server setup
lib.rs # Shared library root
models.rs # TodoRow (FromRow) + Todo/Request/Response (Serialize + TS)
models.rs # TodoRow (FromRow) + Todo/Request/Response (Serialize + typeshare)
handlers.rs # CRUD handlers
queries.rs # Direct sqlx queries (cfg-gated postgres/sqlite variants)
error.rs # AppError → IntoResponse
Expand All @@ -31,7 +31,7 @@ web/
src/
api.ts # Fetch client using generated types
components/ # TodoApp, TodoItem, TodoFooter
types/generated # ts-rs output (re-exported via index.ts)
types/generated # typeshare output (re-exported via index.ts)
scripts/
setup-db.sh # Create database + run migrations
generate-types.sh # Generate TypeScript from Rust structs
Expand Down Expand Up @@ -60,7 +60,7 @@ bash scripts/setup-db.sh
bash scripts/generate-types.sh
```

This runs `cargo test` with `TS_RS_EXPORT_DIR` set, which writes `.ts` files to `web/src/types/generated/`.
This runs the `typeshare` CLI, which parses Rust source files and writes TypeScript interfaces to `web/src/types/generated/types.ts`.

### 3. Start the API

Expand Down Expand Up @@ -107,7 +107,7 @@ The `test-helpers` Cargo feature flag enables `DELETE /api/test/cleanup`, which

## Design decisions

**Separate DB and API types.** `TodoRow` uses `sqlx::FromRow` and maps 1:1 to the database schema (with native `Uuid` and `DateTime<Utc>`). `Todo` uses `Serialize + TS` and represents the JSON shape sent to the client (with `String` IDs and RFC 3339 timestamps). A `From<TodoRow>` impl bridges them. This separation means the database schema and API contract can evolve independently — adding a DB column doesn't force a frontend change until you're ready.
**Separate DB and API types.** `TodoRow` uses `sqlx::FromRow` and maps 1:1 to the database schema (with native `Uuid` and `DateTime<Utc>`). `Todo` uses `Serialize` + `#[typeshare]` and represents the JSON shape sent to the client (with `String` IDs and RFC 3339 timestamps). A `From<TodoRow>` impl bridges them. This separation means the database schema and API contract can evolve independently — adding a DB column doesn't force a frontend change until you're ready.

**Direct SQL, no ORM.** All SQL lives in `src/queries.rs` as compile-time verified `sqlx::query!` calls, with cfg-gated postgres and sqlite variants. Handlers in `src/handlers.rs` are cfg-free and only deal with HTTP/validation logic. The postgres and sqlite query functions are intentionally duplicated rather than abstracted with a macro — see [docs/query-layer-duplication-rationale.md](docs/query-layer-duplication-rationale.md) for the analysis and tradeoffs.

Expand All @@ -117,12 +117,12 @@ The `test-helpers` Cargo feature flag enables `DELETE /api/test/cleanup`, which

## How the type bridge works

Rust structs annotated with `#[derive(TS)]` and `#[ts(export)]` generate TypeScript interfaces when tests run:
Rust structs annotated with `#[typeshare]` generate TypeScript interfaces via the [typeshare CLI](https://github.com/1Password/typeshare):

```rust
#[derive(Serialize, TS)]
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
#[typeshare]
pub struct Todo {
pub id: String,
pub title: String,
Expand All @@ -132,16 +132,16 @@ pub struct Todo {
}
```

Produces:
Running `typeshare ./src --lang=typescript --output-file=web/src/types/generated/types.ts` produces:

```typescript
export type Todo = {
export interface Todo {
id: string;
title: string;
completed: boolean;
createdAt: string;
updatedAt: string;
};
}
```

The frontend imports these types and uses them in the fetch client — if the Rust API shape changes, the TypeScript won't compile until the types are regenerated.
Expand Down
27 changes: 9 additions & 18 deletions scripts/check-types.sh
Original file line number Diff line number Diff line change
@@ -1,26 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail

GENERATED_DIR="web/src/types/generated"
TMP_DIR=$(mktemp -d)
trap 'rm -rf "$TMP_DIR"' EXIT
GENERATED="web/src/types/generated/types.ts"
TMP_FILE=$(mktemp)
trap 'rm -f "$TMP_FILE"' EXIT

echo "Regenerating types into temp directory..."
TS_RS_EXPORT_DIR="$TMP_DIR" cargo test export_bindings --quiet
echo "Regenerating types into temp file..."
typeshare ./src --lang=typescript --output-file="$TMP_FILE"

echo "Comparing against $GENERATED_DIR..."
# Only compare .ts files produced by ts-rs (exclude index.ts barrel)
has_diff=0
for f in "$TMP_DIR"/*.ts; do
name=$(basename "$f")
if ! diff -q "$f" "$GENERATED_DIR/$name" > /dev/null 2>&1; then
echo "MISMATCH: $name"
diff "$f" "$GENERATED_DIR/$name" || true
has_diff=1
fi
done

if [ "$has_diff" -ne 0 ]; then
echo "Comparing against $GENERATED..."
if ! diff -q "$TMP_FILE" "$GENERATED" > /dev/null 2>&1; then
echo "MISMATCH:"
diff "$TMP_FILE" "$GENERATED" || true
echo "Types are out of date. Run scripts/generate-types.sh to update."
exit 1
fi
Expand Down
4 changes: 2 additions & 2 deletions scripts/generate-types.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
set -euo pipefail

echo "Generating TypeScript types from Rust structs..."
TS_RS_EXPORT_DIR=web/src/types/generated cargo test export_bindings --quiet
echo "Done. Types written to web/src/types/generated/"
typeshare ./src --lang=typescript --output-file=web/src/types/generated/types.ts
echo "Done. Types written to web/src/types/generated/types.ts"
4 changes: 2 additions & 2 deletions src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ pub async fn list_todos(

Ok(Json(TodoListResponse {
todos: rows.into_iter().map(Todo::from).collect(),
active_count,
completed_count,
active_count: active_count as i32,
completed_count: completed_count as i32,
}))
}

Expand Down
24 changes: 11 additions & 13 deletions src/models.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#[cfg(feature = "postgres")]
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use typeshare::typeshare;
#[cfg(feature = "postgres")]
use uuid::Uuid;

Expand Down Expand Up @@ -31,9 +31,9 @@ pub struct TodoRow {
pub updated_at: DbTimestamp,
}

#[derive(Debug, Clone, Serialize, TS)]
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
#[typeshare]
pub struct Todo {
pub id: String,
pub title: String,
Expand All @@ -42,26 +42,24 @@ pub struct Todo {
pub updated_at: String,
}

#[derive(Debug, Clone, Serialize, TS)]
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
#[typeshare]
pub struct TodoListResponse {
pub todos: Vec<Todo>,
#[ts(type = "number")]
pub active_count: i64,
#[ts(type = "number")]
pub completed_count: i64,
pub active_count: i32,
pub completed_count: i32,
}

#[derive(Debug, Deserialize, TS)]
#[ts(export)]
#[derive(Debug, Deserialize)]
#[typeshare]
pub struct CreateTodoRequest {
pub title: String,
}

#[derive(Debug, Deserialize, TS)]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
#[typeshare]
pub struct UpdateTodoRequest {
pub title: Option<String>,
pub completed: Option<bool>,
Expand Down
3 changes: 0 additions & 3 deletions web/src/types/generated/CreateTodoRequest.ts

This file was deleted.

3 changes: 0 additions & 3 deletions web/src/types/generated/Todo.ts

This file was deleted.

4 changes: 0 additions & 4 deletions web/src/types/generated/TodoListResponse.ts

This file was deleted.

3 changes: 0 additions & 3 deletions web/src/types/generated/UpdateTodoRequest.ts

This file was deleted.

5 changes: 1 addition & 4 deletions web/src/types/generated/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1 @@
export type { Todo } from './Todo';
export type { TodoListResponse } from './TodoListResponse';
export type { CreateTodoRequest } from './CreateTodoRequest';
export type { UpdateTodoRequest } from './UpdateTodoRequest';
export type { Todo, TodoListResponse, CreateTodoRequest, UpdateTodoRequest } from './types';
27 changes: 27 additions & 0 deletions web/src/types/generated/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
Generated by typeshare 1.13.4
*/

export interface CreateTodoRequest {
title: string;
}

export interface Todo {
id: string;
title: string;
completed: boolean;
createdAt: string;
updatedAt: string;
}

export interface TodoListResponse {
todos: Todo[];
activeCount: number;
completedCount: number;
}

export interface UpdateTodoRequest {
title?: string;
completed?: boolean;
}