A Rust procedural macro library that generates TypeScript type definitions and Zod v4 validation schemas from Rust structs and enums, ensuring type safety and consistency between Rust backends and TypeScript frontends.
Add the following to your Cargo.toml:
[dependencies]
tixschema = <path or crate_id or repo> # eg: { git = "https://github.com/tixena/tixschema.git" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"Important: This crate requires Zod v4 for full functionality, especially JSON schema generation. Zod v3 is not supported.
npm install zod@^4.0.0
# or
yarn add zod@^4.0.0use tixschema::model_schema;
use serde::{Deserialize, Serialize};
#[model_schema()]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct User {
pub id: String,
pub name: String,
pub email: String,
pub age: u32,
pub is_active: bool,
}This generates:
User::ts_definition()-- Returns a TypeScript type definition and Zod schema as a stringUser::json_schema()-- Returns a JSON schema (requiresjsonschemafeature)
Generated TypeScript:
export type User = {
id: string;
name: string;
email: string;
age: number;
is_active: boolean;
};
export const User$Schema: ZodType<User> = z.strictObject({
id: z.string(),
name: z.string(),
email: z.string(),
age: z.number().int(),
is_active: z.boolean(),
});Any Rust struct annotated with #[model_schema()] generates a corresponding TypeScript type and Zod schema. Primitive types map as follows: String to string, bool to boolean, all numeric types to number (integers get .int()), Option<T> to T | undefined, Vec<T> to Array<T>, and HashMap<String, T> to Partial<Record<string, T>>.
#[model_schema()]
#[derive(Serialize, Deserialize)]
pub struct UserProfile {
pub user_id: String,
pub name: String,
pub age: u32,
pub score: f64,
pub is_verified: bool,
}Option<T> fields become T | undefined in TypeScript and z.union([type, z.undefined()]).prefault(undefined) in Zod v4. The .prefault(undefined) makes the field default to undefined when omitted from the input.
#[model_schema()]
#[derive(Serialize, Deserialize)]
pub struct UserWithOptionals {
pub id: String,
pub name: String,
pub email: Option<String>,
pub phone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar_url: Option<String>,
}Vec<T> becomes Array<T>. Only HashMap<String, T> is supported (non-string keys will cause compilation errors).
use std::collections::HashMap;
#[model_schema()]
#[derive(Serialize, Deserialize)]
pub struct UserWithCollections {
pub id: String,
pub tags: Vec<String>,
pub scores: Vec<u32>,
pub metadata: HashMap<String, String>,
pub settings: Option<HashMap<String, String>>,
}Plain enums (all unit variants) generate a TypeScript string union and z.enum() in Zod.
#[model_schema()]
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum UserStatus {
Active,
Inactive,
Pending,
Suspended,
}Enums with #[serde(tag = "...")] generate TypeScript discriminated unions.
#[model_schema()]
#[derive(Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum PaymentMethod {
CreditCard {
card_number: String,
expiry_date: String,
cvv: String,
},
BankTransfer {
account_number: String,
routing_number: String,
},
PayPal {
email: String,
},
}Discriminated unions also support tuple variants. Single-element tuples are flattened to a value field, and multi-element tuples generate a TypeScript tuple type.
#[model_schema()]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub enum FixedValue {
// Unit variant (no data)
Empty,
// Single-element tuple: { type: "Text", value: string }
Text(String),
// Multi-element tuple: { type: "Pair", value: [string, number] }
Pair(String, i64),
// Named struct variant (existing behavior)
Named { field_a: String, field_b: bool },
}Generated TypeScript:
export type FixedValue = {
type: "Empty";
} | {
type: "Text";
value: string;
} | {
type: "Pair";
value: [string, number];
} | {
type: "Named";
field_a: string;
field_b: boolean;
};Generated Zod:
export const FixedValue$Schema: ZodType<FixedValue> = z.discriminatedUnion("type", [
z.strictObject({
type: z.literal("Empty"),
}),
z.strictObject({
type: z.literal("Text"),
value: z.string(),
}),
z.strictObject({
type: z.literal("Pair"),
value: z.tuple([z.string(), z.number().int()]),
}),
z.strictObject({
type: z.literal("Named"),
field_a: z.string(),
field_b: z.boolean(),
}),
]);You can customize the tag and content field names using Serde's tag and content attributes:
#[model_schema()]
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(tag = "kind", content = "data")]
pub enum Event {
Text(String),
Number(i64),
}This generates kind as the discriminator and data as the value field instead of the defaults (type and value).
A struct field marked #[serde(flatten)] is lifted into the parent type as a TypeScript intersection (A & B) and a Zod .and() chain, instead of a nested object. This is the idiomatic way to compose a common set of fields with a discriminated union.
#[model_schema()]
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DataElementSampleValueEntry {
pub data_element_id: String,
#[serde(flatten)]
pub variant: DataElementSampleValueVariant,
}
#[model_schema()]
#[derive(Serialize, Deserialize)]
#[serde(tag = "dataType")]
pub enum DataElementSampleValueVariant {
Alphanumeric {
#[serde(rename = "sampleValues")]
sample_values: Vec<String>,
},
Numeric {
#[serde(rename = "sampleValues")]
sample_values: Vec<i64>,
},
}Generated TypeScript:
export type DataElementSampleValueEntry = {
dataElementId: string;
} & DataElementSampleValueVariant;Generated Zod:
export const DataElementSampleValueEntry$Schema: ZodType<DataElementSampleValueEntry> =
z.strictObject({
dataElementId: z.string(),
}).and(DataElementSampleValueVariant$Schema);Notes:
- Multiple
#[serde(flatten)]fields chain:{ ... } & BasePart & ExtraPartin TypeScript andz.strictObject({ ... }).and(BasePart$Schema).and(ExtraPart$Schema)in Zod. - A struct whose only field is flattened becomes a plain alias (e.g.
export type FlattenOnly = DataElementSampleValueVariant;). - JSON Schema stays strict: rather than
allOf(which cannot faithfully compose a tagged union underadditionalProperties: false), the base properties are distributed into each branch of the flattened union, keeping every branch closed. Plain-struct flattens merge into a single closed object; multiple flattened unions form a cross-product.
An enum marked #[serde(untagged)] serializes as just its content, with no discriminator field. It generates a TypeScript union (A | B), a Zod z.union([...]), and a JSON Schema anyOf. Newtype (S(T)) and named-struct ({ a: A }) variants are supported.
/// ISO-8601 date string; branded newtype carrying the regex pattern.
#[model_schema(pattern = r"^\d{4}-\d{2}-\d{2}$")]
#[derive(Serialize, Deserialize)]
#[serde(transparent)]
pub struct DateString(pub String);
/// A date sample value: an ISO date string OR an epoch number.
#[model_schema()]
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
pub enum DateValue {
N(i64),
S(DateString),
}Generated TypeScript:
export type DateValue = number | DateString;Generated Zod:
const DateValue$RawSchema = z.union([z.number().int(), DateString$Schema]);
export const DateValue$Schema: ZodType<DateValue> = DateValue$RawSchema;Generated JSON Schema:
{ "anyOf": [
{ "type": "integer" },
{ "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$" }
] }Named-struct variants render each member as a closed object:
#[model_schema()]
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
pub enum NamedUnion {
A { x: String },
B { y: i64 },
}// TypeScript
export type NamedUnion = { x: string } | { y: number };
// Zod
z.union([z.strictObject({ x: z.string(), }), z.strictObject({ y: z.number().int(), })])
// JSON Schema: { "anyOf": [ <object>, <object> ] } — each branch has additionalProperties: falseUntagged enums compose with #[serde(flatten)]: a flattened variant carrying Vec<DateValue> renders sampleValues: z.array(DateValue$Schema) (TypeScript Array<DateValue>), and the JSON-schema items for that field is the DateValue anyOf.
Unsupported variants: unit variants and multi-field tuple variants in an untagged enum produce a compile-time error.
Types annotated with #[model_schema()] can reference each other. The TypeScript output uses the type's name (without any suffix) as the reference.
#[model_schema()]
#[derive(Serialize, Deserialize)]
pub struct Address {
pub street: String,
pub city: String,
pub zip_code: String,
}
#[model_schema()]
#[derive(Serialize, Deserialize)]
pub struct UserWithAddress {
pub id: String,
pub name: String,
pub address: Address,
pub backup_addresses: Vec<Address>,
}The library supports recursive and self-referential types. In the generated Zod schema, recursive fields use JavaScript getter syntax to defer the reference and avoid "use before declaration" errors.
The self-reference can be written either with the type's own name (Vec<TreeNode>) or with the Self keyword (Vec<Self>); both produce identical output.
#[model_schema()]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct TreeNode {
pub val: String,
pub children: Vec<TreeNode>,
}Generated TypeScript:
export type TreeNode = {
val: string;
children: Array<TreeNode>;
};Generated Zod:
const TreeNode$RawSchema = z.strictObject({
val: z.string(),
get children() { return z.array(TreeNode$Schema); },
});
export const TreeNode$Schema: ZodType<TreeNode> = TreeNode$RawSchema;Recursive enums are also supported. Only the variants that contain a self-reference use getter syntax; non-recursive variants use normal property syntax:
#[model_schema()]
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(tag = "type", content = "value")]
pub enum DynamicValue {
#[serde(rename = "string")]
String(String),
#[serde(rename = "integer")]
Integer(i64),
#[serde(rename = "array")]
Array(Vec<DynamicValue>),
#[serde(rename = "object")]
Object(HashMap<String, DynamicValue>),
}Generated Zod:
export const DynamicValue$Schema: ZodType<DynamicValue> = z.discriminatedUnion("type", [
z.strictObject({
type: z.literal("string"),
value: z.string(),
}),
z.strictObject({
type: z.literal("integer"),
value: z.number().int(),
}),
z.strictObject({
type: z.literal("array"),
get value() { return z.array(DynamicValue$Schema); },
}),
z.strictObject({
type: z.literal("object"),
get value() { return z.record(z.string(), DynamicValue$Schema); },
}),
]);The #[model_schema()] macro supports type alias statements, creating semantic type aliases that appear in the generated TypeScript output. Use the name argument to control the generated TypeScript name.
use tixschema::model_schema;
#[model_schema(name = "DocumentId")]
pub type DocumentId = String;
#[model_schema(name = "Revision")]
pub type Revision = i64;
#[model_schema(name = "Tags")]
pub type Tags = Vec<String>;Generated TypeScript:
export type DocumentId = string;
export type Revision = number;
export type Tags = Array<string>;Type aliases are referenced by name when used as struct fields:
#[model_schema()]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct DocumentRecord {
pub document_id: DocumentId,
pub revision: Revision,
}Generated TypeScript:
export type DocumentRecord = {
document_id: DocumentId;
revision: Revision;
};#[serde(transparent)] tuple structs with a single public field generate branded TypeScript types. The newtype is invisible in JSON serialization but carries a distinct type identity in TypeScript, preventing accidental mixing of different ID types.
use tixschema::model_schema;
use serde::{Deserialize, Serialize};
// Generic branded newtype (good for parameterized IDs)
#[model_schema()]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct UserId<ID_TYPE>(pub ID_TYPE);
// Non-generic branded newtype
#[model_schema()]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct CorrelationId(pub String);Generated TypeScript (with zod feature):
export type UserId<ID_TYPE> = ID_TYPE & z.$brand<"UserId">;
const UserId$RawSchema = z.string().brand<"UserId">();
export const UserId$Schema: ZodType<UserId<string>> = UserId$RawSchema;
export type CorrelationId = string & z.$brand<"CorrelationId">;
const CorrelationId$RawSchema = z.string().brand<"CorrelationId">();
export const CorrelationId$Schema: ZodType<CorrelationId> = CorrelationId$RawSchema;Generated TypeScript (without zod feature):
declare const __brand_UserId: unique symbol;
export type UserId<ID_TYPE> = ID_TYPE & { readonly [__brand_UserId]: true };
export function assertUserId<ID_TYPE>(value: ID_TYPE): asserts value is UserId<ID_TYPE> {}
declare const __brand_CorrelationId: unique symbol;
export type CorrelationId = string & { readonly [__brand_CorrelationId]: true };
export function assertCorrelationId(value: string): asserts value is CorrelationId {}Notes:
- If the Rust type name ends with
Json, the suffix is stripped in the generated TypeScript (e.g.,UserIdJsonbecomesUserId). Otherwise, the Rust name is used as-is. - Generic parameter names (e.g.,
ID_TYPE) are preserved exactly. - Serde transparent serialization works normally -- the wrapper is invisible in JSON.
- Use branded newtypes for opaque IDs and phantom types to prevent passing the wrong ID type across domain boundaries.
You can add pattern, minLength, and maxLength constraints directly on the #[model_schema()] attribute for branded newtypes. Constraints are enforced in three places: the generated Zod schema, serde deserialization, and a validate() method on the type.
This only works for String inner types. Applying constraints to a non-String branded newtype (e.g., u64) produces a compile-time error.
#[model_schema(pattern = "^[a-z0-9_]+$", minLength = 3, maxLength = 50)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SlugId(pub String);Generated Zod:
const SlugId$RawSchema = z.string()
.min(3)
.max(50)
.check(z.regex(/^[a-z0-9_]+$/))
.brand<"SlugId">()
.meta({
description: "SlugId",
});
export const SlugId$Schema: $ZodBranded<ZodString, "SlugId"> = SlugId$RawSchema;Serde deserialization validates automatically:
// Rejects values that violate constraints
let result: Result<SlugId, _> = serde_json::from_str("\"ab\"");
assert!(result.is_err()); // too short (min 3)
let result: Result<SlugId, _> = serde_json::from_str("\"UPPERCASE\"");
assert!(result.is_err()); // pattern mismatch
// Accepts valid values
let result: Result<SlugId, _> = serde_json::from_str("\"hello_world\"");
assert!(result.is_ok());The validate() method checks constraints on instances constructed directly in code:
let slug = SlugId("hello_world".to_string());
assert!(slug.validate().is_ok());
let bad = SlugId("ab".to_string());
match bad.validate() {
Ok(()) => unreachable!(),
Err(errors) => println!("{:?}", errors), // ["'value' is too short: minimum length is 3, got 2"]
}You can use any combination of the three constraints:
// Pattern only -- e.g., ObjectId hex string
#[model_schema(pattern = "^[0-9a-fA-F]{24}$")]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ObjectIdStr(pub String);
// Length only -- no pattern
#[model_schema(minLength = 1, maxLength = 255)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct NonEmptyString(pub String);Compile-time error for non-String types:
// This will NOT compile:
#[model_schema(pattern = "^[0-9]+$")]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct BadNum(pub u64);
// Error: model_schema constraints (pattern, minLength, maxLength) are only
// supported on String branded newtypes, but `BadNum` has inner type `u64`Branded newtypes support doc comments (for Zod .meta({ description })) and compiler-validated examples, just like structs and enums:
/// Generic document identifier.
///
/// - `DocumentId<String>` for API/HTTP layer
/// - `DocumentId<ObjectId>` for MongoDB layer
///
/// ```rust example
/// DocumentId("64de3d95ff45b119e5b53a7e".to_string())
/// ```
#[model_schema()]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct DocumentId<ID_TYPE>(pub ID_TYPE);Generated Zod:
const DocumentId$RawSchema = z.string().brand<"DocumentId">().meta({
description: "Generic document identifier.\n...",
example: "64de3d95ff45b119e5b53a7e",
});
export const DocumentId$Schema: $ZodBranded<ZodString, "DocumentId"> = DocumentId$RawSchema;Use #[model_schema_prop(...)] on individual fields to add validation constraints, override types, or apply Zod preprocessing. Constraints are enforced in both Zod (frontend) and a generated validate() method (Rust).
use tixschema::{model_schema, model_schema_prop};
use serde::{Deserialize, Serialize};
#[model_schema()]
#[derive(Serialize, Deserialize)]
pub struct UserProfile {
#[model_schema_prop(minLength = 3, maxLength = 30, pattern = "^[a-z0-9_]+$")]
pub username: String,
#[model_schema_prop(maxLength = 200)]
pub bio: String,
#[model_schema_prop(pattern = "^[0-9a-fA-F]{24}$")]
pub external_id: String,
}Generated Zod for username: z.string().min(3).max(30).check(z.regex(/^[a-z0-9_]+$/))
Generated JSON Schema for username: { "type": "string", "minLength": 3, "maxLength": 30, "pattern": "^[a-z0-9_]+$" }
The TypeScript type is unchanged -- still just string.
#[model_schema()]
#[derive(Serialize, Deserialize)]
pub struct Product {
#[model_schema_prop(minimum = 0, maximum = 120)]
pub age_restriction: u32,
#[model_schema_prop(minimum = 0.0)]
pub price: f64,
}When any field carries a constraint, the macro generates a validate(&self) -> Result<(), Vec<String>> method. Use this to validate instances constructed directly in Rust code (serde deserialization validates automatically on the way in).
#[model_schema()]
#[derive(Serialize, Deserialize)]
pub struct Registration {
#[model_schema_prop(minLength = 3, maxLength = 30)]
pub username: String,
#[model_schema_prop(minimum = 0, maximum = 120)]
pub age: u32,
}
let reg = Registration { username: "ab".to_string(), age: 150 };
match reg.validate() {
Ok(()) => println!("valid"),
Err(errors) => {
for e in &errors {
println!("Error: {e}");
}
// "username: too short (minimum length 3, got 2)"
// "age: too large (maximum 120, got 150)"
}
}The macro also generates into the type's schema module:
validate_{field}_value(&FieldType) -> Result<(), String>-- pure static validator per fielddeserialize_{field}(D) -> Result<FieldType, E>-- serde hook that calls the static validator
You can constrain a String field to a specific literal value using model_schema_prop(literal = "value"). This generates z.literal("value") in the Zod schema and a literal type in TypeScript, while the Rust field remains a String.
#[model_schema()]
#[derive(Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum Action {
Generate {
#[model_schema_prop(literal = "document")]
value: String,
},
}Generated:
export type Action = {
type: "generate";
value: "document";
};
export const Action$Schema = z.discriminatedUnion("type", [
z.strictObject({
type: z.literal("generate"),
value: z.literal("document"),
}),
]);Recommended: Single-value enums over literal strings. While model_schema_prop(literal = ...) works, a single-value enum provides type safety in Rust -- the field can only hold the correct value at compile time, whereas a String with a literal annotation can hold any string in Rust (the constraint only applies in the generated TypeScript/Zod output).
// Correct: single-value enum
#[model_schema()]
#[derive(Serialize, Deserialize)]
pub enum DocumentLiteralValue {
#[serde(rename = "document")]
Document,
}
#[model_schema()]
#[derive(Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum Action {
Generate {
value: DocumentLiteralValue, // Type-safe in Rust
},
}
// Wrong: not recommended
#[model_schema()]
#[derive(Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ActionAlt {
Generate {
#[model_schema_prop(literal = "document")]
value: String, // Any string value accepted in Rust
},
}Both approaches produce identical TypeScript and Zod output. The single-value enum is preferred because:
- Type safety in Rust -- impossible to construct with a wrong value
- Self-documenting -- the enum name makes the intent clear
- Pattern matching -- match on
DocumentLiteralValue::Documentinstead of checking string equality - Naming convention: Use the
<Something>LiteralValuenaming pattern (e.g.,DocumentLiteralValue)
Use as to override the TypeScript/Zod type for a field, keeping the Rust type unchanged:
#[model_schema()]
#[derive(Serialize, Deserialize)]
pub struct ApiConfig {
pub id: String,
#[model_schema_prop(as = String)]
pub metric_type: String,
pub enabled: bool,
}preprocess = ["fn1", "fn2"] wraps the Zod schema with z.preprocess() calls. This is Zod-only -- no effect on Rust types or serde deserialization. Multiple preprocessors are applied as nested calls.
#[model_schema()]
#[derive(Serialize, Deserialize)]
pub struct Event {
// generates: z.preprocess(epochToDate, z.string())
#[model_schema_prop(preprocess = ["epochToDate"])]
pub created_at: String,
// generates: z.preprocess(trim, z.preprocess(normalize, z.string()))
#[model_schema_prop(preprocess = ["trim", "normalize"])]
pub name: String,
}By default an Option<T> field renders as a required key carrying | undefined (field: T | undefined). Add the bare ts_optional flag to render it as an optional key instead (field?: T).
This is a TypeScript-only knob -- the Zod schema and JSON Schema are unchanged (the field is already optional in both). The flag is only valid on Option<T> fields; applying it to a non-Option field is a compile error. It composes with as = Type.
#[model_schema()]
#[derive(Serialize, Deserialize)]
pub struct Profile {
pub name: String,
#[model_schema_prop(ts_optional)]
pub nickname: Option<String>,
}Generated TypeScript:
export type Profile = {
name: string;
nickname?: string;
};Without ts_optional, nickname would render as nickname: string | undefined.
You can provide compiler-validated example values for your types that will be embedded in the generated Zod schemas' .meta() field. Examples are written in doc comment code blocks and fully type-checked at compile time.
/// User profile
/// ```rust example
/// User {
/// name: "John Doe".to_string(),
/// email: "john@example.com".to_string(),
/// age: 25,
/// }
/// ```
#[model_schema()]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct User {
pub name: String,
pub email: String,
pub age: u32,
}Generated Zod:
export const User$Schema: ZodType<User> = z.strictObject({
name: z.string(),
email: z.string(),
age: z.number().int(),
}).meta({
example: { name: "John Doe", email: "john@example.com", age: 25 }
});You can include arbitrary setup code before the example value:
/// User with tags
/// ```rust example
/// let user_id = "usr_123".to_string();
/// let tags = vec!["admin".to_string(), "active".to_string()];
/// User {
/// id: user_id,
/// tags,
/// age: 30,
/// }
/// ```
#[model_schema()]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct User {
pub id: String,
pub tags: Vec<String>,
pub age: u32,
}Enum examples work similarly:
/// Data type enumeration
/// ```rust example
/// DataType::Numeric
/// ```
#[model_schema()]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub enum DataType {
Alphanumeric,
Numeric,
Date,
}Key points:
- Examples are optional -- if not provided, no example is generated.
- Use the exact syntax:
```rust example(note the space andexamplekeyword). - If multiple examples are present, only the first one is used.
- Examples respect Serde attributes (field renaming, etc.).
- The example code is executed at compile time and serialized to JSON.
- Wrong types produce compile errors, ensuring examples stay in sync with your types.
Enable the object_id feature for first-class MongoDB ObjectId support with proper serialization and validation.
use tixschema::model_schema;
use serde::{Deserialize, Serialize};
use mongodb::bson::oid::ObjectId;
#[model_schema()]
#[derive(Serialize, Deserialize)]
pub struct Document {
pub id: ObjectId,
pub title: String,
pub author_id: ObjectId,
pub tags: Vec<ObjectId>,
pub metadata: HashMap<String, ObjectId>,
pub parent_id: Option<ObjectId>,
pub related_docs: HashMap<String, Vec<ObjectId>>,
}Generated TypeScript:
export type Document = {
id: ObjectId;
title: string;
author_id: ObjectId;
tags: Array<ObjectId>;
metadata: Partial<Record<string, ObjectId>>;
parent_id: ObjectId | undefined;
related_docs: Partial<Record<string, Array<ObjectId>>>;
};
export const Document$Schema = z.strictObject({
id: z.object({ $oid: z.string().regex(/^[a-f\d]{24}$/i, { message: "Invalid ObjectId" }) }),
title: z.string(),
author_id: z.object({ $oid: z.string().regex(/^[a-f\d]{24}$/i, { message: "Invalid ObjectId" }) }),
tags: z.array(z.object({ $oid: z.string().regex(/^[a-f\d]{24}$/i, { message: "Invalid ObjectId" }) })),
metadata: z.record(z.string(), z.object({ $oid: z.string().regex(/^[a-f\d]{24}$/i, { message: "Invalid ObjectId" }) })),
parent_id: z.union([z.object({ $oid: z.string().regex(/^[a-f\d]{24}$/i, { message: "Invalid ObjectId" }) }), z.undefined()]).prefault(undefined),
related_docs: z.record(z.string(), z.array(z.object({ $oid: z.string().regex(/^[a-f\d]{24}$/i, { message: "Invalid ObjectId" }) }))),
});ObjectIds serialize to MongoDB's standard JSON format:
{
"id": { "$oid": "507f1f77bcf86cd799439011" },
"title": "My Document",
"author_id": { "$oid": "507f1f77bcf86cd799439012" },
"tags": [
{ "$oid": "507f1f77bcf86cd799439013" },
{ "$oid": "507f1f77bcf86cd799439014" }
]
}Key details:
- Uses
{ "$oid": "hex_string" }format matching MongoDB's native serialization. - Validates 24-character hexadecimal ObjectId strings via regex.
- Supports ObjectIds in arrays, HashMaps, optional fields, and deeply nested structures.
- The MongoDB crate is a dev-dependency only -- zero production overhead.
Enable the chrono feature for chrono date/time type support. All chrono types map to string in TypeScript, with appropriate Zod validation for the specific date/time format.
tixschema = { features = ["chrono"] }Supported types and mappings:
| Rust Type | TypeScript | Zod Schema | JSON Schema Format |
|---|---|---|---|
NaiveDate |
string |
z.iso.date() |
"date" |
NaiveTime |
string |
z.preprocess(<millis arrow>, z.iso.time()) |
"time" |
NaiveDateTime |
string |
z.iso.datetime({ local: true }) |
"date-time" |
DateTime<Tz> (default) |
Date |
z.coerce.date() |
"date-time" |
DateTime<Tz> + #[model_schema_prop(as_number)] |
number |
z.preprocess(<epoch arrow>, z.number()) |
"date-time" |
DateTime<Tz> renders as a native TypeScript Date (z.coerce.date()) by default, which is what MongoDB needs to expire a BSON Date via TTL. The bare as_number flag opts a single DateTime<Tz> field into an epoch-milliseconds number instead, validated by a self-contained inline coercer (no imported helper). as_number is only valid on a DateTime<Tz> field — using it elsewhere is a compile error.
NaiveTime stays a TypeScript string, but its Zod schema also accepts millis-since-start-of-day, converting them to an HH:MM:SS string before validation.
Example:
use tixschema::{model_schema, model_schema_prop};
use serde::{Deserialize, Serialize};
use chrono::{NaiveDate, NaiveTime, NaiveDateTime, DateTime, Utc};
#[model_schema()]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Event {
pub name: String,
pub date: NaiveDate,
pub time: NaiveTime,
pub local_datetime: NaiveDateTime,
pub created_at: DateTime<Utc>,
#[model_schema_prop(as_number)]
pub epoch_ms: DateTime<Utc>,
pub updated_at: Option<DateTime<Utc>>,
}Generated TypeScript:
export type Event = {
name: string;
date: string;
time: string;
local_datetime: string;
created_at: Date;
epoch_ms: number;
updated_at: Date | undefined;
};Generated Zod:
export const Event$Schema: ZodType<Event> = z.strictObject({
name: z.string(),
date: z.iso.date(),
time: z.preprocess((arg) => { if (typeof arg === "number") { const s = Math.floor(arg / 1000); const hh = String(Math.floor(s / 3600)).padStart(2, "0"); const mm = String(Math.floor((s % 3600) / 60)).padStart(2, "0"); const ss = String(s % 60).padStart(2, "0"); return `${hh}:${mm}:${ss}`; } return arg; }, z.iso.time()),
local_datetime: z.iso.datetime({ local: true }),
created_at: z.coerce.date(),
epoch_ms: z.preprocess((arg) => { if (arg instanceof Date) return arg.getTime(); if (typeof arg === "string") return Date.parse(arg); return arg; }, z.number()),
updated_at: z.union([z.coerce.date(), z.undefined()]).prefault(undefined),
});Chrono types also work in collections (Vec<NaiveDate> generates z.array(z.iso.date())) and in enums as tuple variant elements (as_number is honored on a tuple-variant DateTime<Tz> payload).
The crate uses optional features to control code generation and dependencies. All features can be independently enabled or disabled.
| Feature | Default | Description |
|---|---|---|
serde |
Yes | Serde attribute parsing (rename, rename_all, tag, etc.) |
zod |
Yes | Zod v4 schema generation alongside TypeScript types |
jsonschema |
Yes | JSON Schema generation via json_schema() method |
typescript |
Yes | TypeScript type generation via ts_definition() method |
object_id |
No | MongoDB ObjectId type support with validation |
chrono |
No | Chrono date/time type support (NaiveDate, NaiveTime, NaiveDateTime, DateTime<Tz>) |
Common configurations:
# Default (serde + zod + jsonschema + typescript)
tixschema = "0.1.0"
# All features including optional ones
tixschema = { features = ["serde", "zod", "jsonschema", "typescript", "object_id", "chrono"] }
# Minimal (TypeScript only, no Zod or JSON Schema)
tixschema = { default-features = false, features = ["typescript"] }
# TypeScript + Zod without JSON Schema
tixschema = { default-features = false, features = ["serde", "zod", "typescript"] }All 2^6 = 64 feature combinations are tested in CI via cargo-hack.
Create a utility function to generate TypeScript files with all your types:
use std::fs;
pub enum MyEntities {}
impl MyEntities {
pub fn get_entities() -> (String, Vec<String>) {
(
"Generated Types".to_string(),
vec![
User::ts_definition(),
UserStatus::ts_definition(),
PaymentMethod::ts_definition(),
Address::ts_definition(),
],
)
}
}
pub fn generate_ts_schemas(target_path: &str) -> Result<(), Box<dyn std::error::Error>> {
let mut file_contents = String::from("import { z } from \"zod\";\n\n");
let (header, type_definitions) = MyEntities::get_entities();
file_contents.push_str(&format!("/*\n * {}\n */\n\n", header));
file_contents.push_str(&type_definitions.join("\n\n"));
file_contents.push('\n');
fs::write(target_path, file_contents)?;
Ok(())
}
// Run generation as a test
#[test]
fn generate_typescript() {
generate_ts_schemas("../frontend/src/types/generated.ts").unwrap();
}There are several ways to run the generation:
- Test-based generation (shown above): Run
cargo test generate_typescriptto produce the file. Simple and works well for most projects. - Binary crate: Create a small binary that imports your types and calls
ts_definition(). Run it withcargo run --bin generate_types. justcommand: Wrapcargo test --test generationin a justfile target for a convenientjust generate-tscommand.
Note: build.rs is not recommended for proc-macro libraries since the types are not available during the build script phase.
- Run your TypeScript generation:
cargo test generate_typescript - The generated file will include all your types and schemas
- Import and use in your TypeScript/JavaScript code:
import { z } from "zod";
import { User, User$Schema } from './types/generated';
// Runtime validation
const userData = User$Schema.parse(apiResponse);
// Type-safe usage
const user: User = {
id: "123",
name: "John Doe",
email: "john@example.com",
age: 30,
is_active: true
};You can also generate JSON schemas from the Zod schemas for API documentation or OpenAPI specs:
import { generateSchema } from '@zod-schema/json-schema';
import { User$Schema } from './types/generated';
const jsonSchema = generateSchema(User$Schema);The macro respects Serde attributes when the serde feature is enabled:
#[model_schema()]
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserProfile {
pub user_id: String, // becomes userId in TypeScript
pub first_name: String, // becomes firstName in TypeScript
pub last_name: String, // becomes lastName in TypeScript
#[serde(rename = "emailAddress")]
pub email: String, // becomes emailAddress in TypeScript
pub created_at: String, // becomes createdAt in TypeScript
}Supported Serde attributes:
#[serde(rename = "...")]-- rename individual fields#[serde(rename_all = "camelCase")]-- rename all fields with a naming convention#[serde(tag = "...")]-- set the discriminator field for tagged enums#[serde(tag = "...", content = "...")]-- adjacently tagged enums#[serde(untagged)]-- untagged enums generate a union (A | B) / Zodz.union([...])/ JSON SchemaanyOf#[serde(flatten)]-- flatten a field into the parent as an intersection type (A & B) / Zod.and(...)#[serde(transparent)]-- transparent wrappers (used for branded newtypes)#[serde(skip_serializing_if = "...")]-- respected but does not affect generated types
If the serde feature is disabled but serde attributes are present, you will see compile-time warnings and field names will not be transformed.
-
Naming Convention: The
Jsonsuffix on Rust type names is optional. If present, it is automatically stripped in the generated TypeScript output (e.g.,UserJsonbecomesUser). If noJsonsuffix is present, the Rust type name is used as-is (e.g.,UserstaysUser). -
Type References: Nested types reference each other by their TypeScript name (with the
Jsonsuffix stripped if present). -
HashMap Keys: Only
HashMap<String, T>is supported. Non-string keys will cause compilation errors. -
Array Types:
Vec<T>becomesArray<T>in TypeScript. -
Optional Fields:
Option<T>becomesT | undefinedin TypeScript andz.union([type, z.undefined()]).prefault(undefined)in Zod v4. -
Complex Nesting: The crate supports deeply nested structures including
HashMap<String, Vec<HashMap<String, T>>>and similar patterns. -
Doc Comments: Rust doc comments on types and fields are carried through to the generated TypeScript as JSDoc comments.
-
Built-in Type Mappings: Besides the primitives,
Stringandstd::path::PathBufboth map tostring(z.string()), andserde_json::Valuemaps tounknown(z.unknown()). MongoDBObjectIdand thechronodate/time types are supported behind their respective feature flags.
Error: cannot find type 'ObjectId' in this scope
Cause: Using ObjectId without the object_id feature enabled.
Solutions:
# Option 1: Enable the object_id feature
tixschema = { features = ["object_id"] }
# Option 2: Use full path in your code
# use mongodb::bson::oid::ObjectId;Error: no function or associated item named 'json_schema' found
Cause: Calling .json_schema() without the jsonschema feature enabled.
Solution:
tixschema = { features = ["jsonschema"] }Symptom: Generated TypeScript contains only types, no Zod schemas.
Cause: zod feature is disabled.
Solution:
tixschema = { features = ["zod"] }Symptom: Field names not transformed (e.g., user_id instead of userId).
Cause: serde feature is disabled.
Solution:
tixschema = { features = ["serde"] }Error: Compilation fails with complex HashMap key types.
Only HashMap<String, T> is supported:
// Wrong: not supported
pub struct BadConfig {
pub settings: HashMap<i32, String>,
}
// Correct: use string keys
pub struct Config {
pub settings: HashMap<String, String>,
}Error: Various compilation errors related to traits.
Always include required derives:
#[model_schema()]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct MyType {
// fields...
}Error: Cannot find name 'ObjectId' in TypeScript.
Solution: Define the ObjectId type in your TypeScript project:
// types/mongodb.ts
export interface ObjectId {
$oid: string;
}Problem: Data doesn't validate against generated schemas.
Common causes:
- Field naming mismatch: Check serde attributes and feature flags.
- Optional field handling: Ensure consistent
Option<T>usage. - ObjectId format: Must be
{ "$oid": "hex_string" }format.
Debug by printing generated output:
println!("{}", MyType::ts_definition());
#[cfg(feature = "jsonschema")]
println!("{}", serde_json::to_string_pretty(&MyType::json_schema()).unwrap());This library generates Zod v4 compatible schemas exclusively. The key difference from Zod v3 is how optional fields are handled:
Zod v4 (generated by this library):
export const User$Schema = z.strictObject({
id: z.string(),
name: z.string(),
email: z.union([z.string(), z.undefined()]).prefault(undefined),
age: z.union([z.number().int(), z.undefined()]).prefault(undefined),
});Zod v3 style (not supported):
// This format is NOT generated and will not work
export const User$Schema = z.strictObject({
id: z.string(),
name: z.string(),
email: z.string().optional(),
age: z.number().int().optional(),
}).transform(args => Object.assign(args, {
email: args.email,
age: args.age
}));Benefits of the Zod v4 approach:
- JSON Schema generation: Zod v4 can generate JSON schemas directly from the validation schemas.
- Cleaner code: No complex transform functions needed.
- Better performance: Eliminates runtime transform overhead.
- Type safety: Maintains the same
T | undefinedTypeScript semantics.