typespec-rust-emitter
v0.9.0
Published
TypeSpec emitter that generates idiomatic Rust types and structs
Maintainers
Readme
Authored by opencode & Qwen
TypeSpec Rust Emitter
A TypeSpec emitter that generates idiomatic Rust types and structs from TypeSpec specifications.
Features
- Models: Converts TypeSpec models to Rust structs with serde derive macros
- Enums: Supports both string and integer enums with
Defaultderive - Unions: Handles nullable types (
T | null→Option<T>) and string literal unions - Scalars: Maps TypeSpec scalars to Rust equivalents with
@formatsupport - Inheritance: Supports model inheritance with
getAllProperties() - Error Models: Generates
thiserror::Errorderive with#[error(...)]attributes andIntoResponseimpl for axum - Pattern Validation: Supports
@patterndecorators withTryFrom<String>validation - Custom Derives: Add arbitrary Rust derive macros via
@rustDerivedecorator (models & enums) - Custom Attributes: Add arbitrary Rust attributes via
@rustAttrdecorator (models & enums) - Server Trait: Generates axum server trait and router from HTTP operations
Installation
npm install typespec-rust-emitterPeer Dependencies
This package requires the following peer dependencies:
npm install @typespec/compiler @typespec/emitter-frameworkUsage
Basic Model
import "typespec-emitter";
model User {
name: string;
age: int32;
email: string | null;
}Generates:
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct User {
#[serde(rename = "name")]
pub name: String,
#[serde(rename = "age")]
pub age: i32,
#[serde(rename = "email")]
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
}String Enum
enum Status {
active,
inactive,
pending,
}Generates:
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
pub enum Status {
#[default]
#[serde(rename = "active")]
Active,
#[serde(rename = "inactive")]
Inactive,
#[serde(rename = "pending")]
Pending,
}Error Model
@error
model ApiError {
code: string;
message: string;
}Generates:
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, thiserror::Error)]
#[error("{code}: {message}")]
pub struct ApiError {
#[serde(rename = "code")]
pub code: String,
#[serde(rename = "message")]
pub message: String,
}
impl IntoResponse for ApiError {
fn into_response(self) -> axum::response::Response {
(
StatusCode::from_str(&self.code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
Json(self),
)
.into_response()
}
}Error codes are parsed dynamically, so any valid HTTP status code string (e.g., "NOT_FOUND", "BAD_REQUEST", "418 I'M_A_TEAPOT") will work.
UUID Format
@format("uuid")
scalar Uuid extends string;Generates:
pub type Uuid = uuid::Uuid;Pattern Validation
@pattern("^#[0-9A-Fa-f]{6}$")
scalar HexColor extends string;Generates:
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct HexColor(pub String);
impl TryFrom<String> for HexColor {
type Error = String;
fn try_from(value: String) -> Result<Self, Self::Error> {
let re = regex::Regex::new(r"^#[0-9A-Fa-f]{6}$").unwrap();
if re.is_match(&value) { Ok(Self(value)) } else { Err(format!("Invalid value: {}", value)) }
}
}Custom Derives
import "typespec-emitter";
@rustDerive("sqlx::FromRow")
@rustDerive("derive_more::Display")
model GroupStatistics {
id: int64;
name: string;
}Generates:
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow, derive_more::Display)]
pub struct GroupStatistics {
#[serde(rename = "id")]
pub id: i64,
#[serde(rename = "name")]
pub name: String,
}Custom Attributes
import "typespec-emitter";
@rustAttr("sqlx(type_name = \"user\")")
model User {
name: string;
}
@rustDerive("sqlx::Type")
@rustAttr("sqlx(type_name = \"study_status\")")
enum StudyStatus {
Starting,
Paused,
}Generates:
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[sqlx(type_name = "user")]
pub struct User {
#[serde(rename = "name")]
pub name: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize, sqlx::Type)]
#[sqlx(type_name = "study_status")]
pub enum StudyStatus {
#[default]
#[serde(rename = "Starting")]
Starting,
#[serde(rename = "Paused")]
Paused,
}Server Trait Generation
The emitter can generate a complete axum server trait and router from TypeSpec HTTP operations.
Example TypeSpec
@route("/groups")
namespace Groups {
@get
@route("")
op list(): Group[];
@post
@route("")
@useAuth(BearerAuth)
op create(@body body: CreateGroupBody): Group;
@get
@route("/{id}")
op getById(@path id: int64): Group;
}Generated Server Trait
#[async_trait]
pub trait Server: Send + Sync {
type Claims: Send + Sync + 'static;
async fn groups_list(&self) -> Result<GroupsListResponse>;
async fn groups_create(&self, claims: Self::Claims, body: CreateGroupBody) -> Result<GroupsCreateResponse>;
async fn groups_get_by_id(&self, id: i64) -> Result<GroupsGetByIdResponse>;
}Implementing the Server
#[derive(Clone)]
pub struct AppState {
pub db: Arc<SqlitePool>,
}
pub struct MyServer {
state: AppState,
}
#[async_trait::async_trait]
impl Server for MyServer {
type Claims = Claims; // Your custom claims type
async fn groups_list(&self) -> eyre::Result<GroupsListResponse> {
let groups = sqlx::query_as::<_, Group>("SELECT * FROM groups")
.fetch_all(&self.state.db)
.await?;
Ok(GroupsListResponse::Ok(Json(groups)))
}
async fn groups_create(
&self,
claims: Self::Claims,
body: CreateGroupBody,
) -> eyre::Result<GroupsCreateResponse> {
// Direct access to body fields
let group = sqlx::query_as::<_, Group>(
"INSERT INTO groups (name, owner_id) VALUES ($1, $2) RETURNING *"
)
.bind(&body.name)
.bind(&claims.sub)
.fetch_one(&self.state.db)
.await?;
Ok(GroupsCreateResponse::Created(Json(group)))
}
async fn groups_get_by_id(&self, id: i64) -> eyre::Result<GroupsGetByIdResponse> {
// Direct access to path parameter
let group = sqlx::query_as::<_, Group>("SELECT * FROM groups WHERE id = $1")
.bind(id)
.fetch_optional(&self.state.db)
.await?
.ok_or_else(|| GroupsGetByIdResponse::NotFound)?;
Ok(GroupsGetByIdResponse::Ok(Json(group)))
}
// ... implement other methods
}Creating the Router
// Define your claims type
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Claims {
pub sub: String,
pub exp: usize,
}
// Create auth middleware
async fn auth_middleware(
Extension(claims): Extension<Claims>,
request: Request<axum::body::Body>,
next: Next,
) -> Result<Response, StatusCode> {
// Validate claims (e.g., check expiration)
if claims.exp < (chrono::Utc::now().timestamp() as usize) {
return Err(StatusCode::UNAUTHORIZED);
}
Ok(next.run(request).await)
}
// Apply middleware to protected routes
fn with_auth<S>(router: Router<S>) -> Router<S>
where
S: Server + Clone + Send + Sync + 'static,
S::Claims: Send + Sync + Clone + 'static,
{
router.layer(axum::middleware::from_fn(auth_middleware))
}
// Create the router
let server = MyServer::new(state);
let router = create_router(server, with_auth);
axum::serve(listener, router).await?;Protected Routes
Operations decorated with @useAuth will:
- Receive
claims: Self::Claimsas the first parameter - Be wrapped with the middleware passed to
create_router
Public routes do not receive claims and are not wrapped with auth middleware.
Type Mappings
| TypeSpec Type | Rust Type |
| ---------------- | ------------------------------------------- |
| string | String |
| int8/16/32/64 | i8/i16/i32/i64 |
| uint8/16/32/64 | u8/u16/u32/u64 |
| float32/64 | f32/f64 |
| boolean | bool |
| bytes | Vec<u8> |
| string[] | Vec<String> |
| Record<string> | std::collections::HashMap<String, String> |
| T \| null | Option<T> |
| utcDateTime | chrono::DateTime<chrono::Utc> |
| offsetDateTime | chrono::DateTime<chrono::FixedOffset> |
| plainDateTime | chrono::NaiveDateTime |
| plainDate | chrono::NaiveDate |
| plainTime | chrono::NaiveTime |
Format Mappings
| @format() | Rust Type |
| ------------- | ----------------------- |
| "uuid" | uuid::Uuid |
| "date" | chrono::NaiveDate |
| "time" | chrono::NaiveTime |
| "date-time" | chrono::DateTime<Utc> |
Decorators
| Decorator | Description |
| ---------------------------- | ----------------------------------------------------- |
| @error | Adds thiserror::Error derive with error message |
| @pattern("regex") | Generates TryFrom<String> with regex validation |
| @rustDerive("...") | Adds a custom derive macro (models & enums) |
| @rustDerives("...", "...") | Adds multiple custom derive macros (models & enums) |
| @rustAttr("...") | Adds a custom Rust attribute (models & enums) |
| @rustAttrs("...", "...") | Adds multiple custom Rust attributes (models & enums) |
| @doc("...") | Generates /// doc comments |
| @useAuth(AuthType) | Marks operation as protected, adds Claims parameter |
Building
npm run buildTesting
npm testDevelopment
The emitter is built using the TypeSpec emitter framework. Key files:
src/emitter.ts- Main emitter implementationsrc/lib.tsp- TypeSpec decorator declarationssrc/index.ts- Public exportstest/hello.test.ts- Unit testsexample/- Example TypeSpec definitions and generated Rust output
License
MIT
