| Crates.io | type_reflect |
| lib.rs | type_reflect |
| version | 0.6.2 |
| created_at | 2023-11-25 16:20:48.174974+00 |
| updated_at | 2025-05-30 05:14:13.971782+00 |
| description | Extensible runtime reflection through a Derive macro |
| homepage | |
| repository | https://github.com/spencerkohan/type_reflect/tree/main/type_reflect |
| max_upload_size | |
| id | 1048407 |
| size | 263,008 |
This crate is part of a larger workspace, see the monorepo README for more details
This project is currently fairly rough around the edges. It's already usable in it's current state, but for instance the rust docs are WIP. You may also expect to run into bugs and limitations, since so far it has only been tested on my own use-cases. If you run into something, please report it so I can make this a better tool
This library is implemented to make it easier to bridge between Rust types and Zod schemas for TypeScript.
This can be useful, for instance, in the context of a Rust Back-end service interacting with a TypeScript front-end.
Other solutions exist to solve similar problems, for instance the excellent ts-rs crate, which I borrow from heavily, but these did not solve the specific problem I had which was to generate a Zod schema from a Rust type.
So for example, if I have this type:
struct Foo {
name: String,
id: u32,
value: f64
}
This crate provides a way to automatically generate a Zod schema like so:
export const FooSchema = z.object({
name: z.string(),
id: z.number(),
value: z.number(),
});
export type Foo = z.infer<typeof FooSchema>;
Toward that goal, this crate implements a procedural macro, which grants runtime type reflection to Rust types.
This crate is opinionated. It's intended to make it as easy as possible to make a pragmatic subset of all types expressable in Rust easily sharable with other languages, and TypeScript in particular.
The goals of this crate are:
Non-goals:
The Reflect macro has support for certain serde attributes to make it easier to keep all representations aligned with the serialized representation.
Specifically this includes:
This attribute is commonly used to convert between case conventions, like snake_case and camelCase for keys.
So for example, a rust snake_case representation can be converted to camelCase in the Zod output by using this attribute:
#[derive(Reflect, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Foo {
key_1: ...
key_2: ...
}
would result in the Zod export using the key names key1 and key2.
For enum types with associated data, the tag attribute is requires. So for instance this declaration:
#[derive(Reflect, Serialize, Deserialize)]
enum MyEnum {
VariantA { x: u32 }
VariantB { text: String }
}
will throw an error. The reason for this is that by default, serde uses externally tagged JSON representation of enums. I.e. the above code would serialize to:
{ "VariantA": { "x": 42 }}
{ "VariantB": { "text": "foo" }}
This type of enum representation is disallowed by type_reflect because it is less convenient to bridge to typescript union types, which are the best analog for ADT's in typescript.
DefaultDue to the way emitters are instantiated by export_types!, it's requried for emitters to implement the Default trait if they are declared as a destination in the macro.
So for example this destination:
MyEmitter(...)
desugars to this:
let mut MyEmitter {
..Default::default()
}
This is required to support passing named parameters to an emitter with defaults.
Default was not made a requirement of the trait, in order to support creating an emitter instance outside the context of the macro.
A working example can be found at type_reflect/examples/declare_and_export.rs.
It can be run with the command: cargo run -p type_reflect --example declare_and_export
the output files will be written to: type_reflect/example_output
#[derive(Reflect)]
struct MyStruct {
foo: i32,
bar: String,
baz: MyOtherType
}
export_types!(
types: [
MyStruct,
MyOtherType,
]
exports: [
Zod("/export/dir/1"),
Rust("/export/dir/2", "/export/dir/3"),
MyCustomExport("/export/dir/4")
]
)
Where export_types desugars to:
let mut emitter = Zod {
..Default::default()
};
let mut file = emitter
.init_destination_file(
"/export/dir/1",
"",
)?;
file.write_all(emitter.emit::<MyStruct>().as_bytes())?;
file.write_all(emitter.emit::<MyOtherType>().as_bytes())?;
emitter.finalize("/export/dir/1")?;
...
Here all directories are relative to the current working director from which the binary is executed.
It's also possible to support a custom prefix for output files.
This may be useful, for instance, if we want to have an exported type depend on a type defined directly in the destination project.
For instance, let's say we have a type defined and exported like so:
#[define(Reflect, Serialize, Deserialize)]
struct Foo {
bar: Bar
}
...
export_types!(
types: [
Foo,
]
exports: [
TypeScript("./export/foo.ts"),
]
)
By default, this will result in the following .export/foo.ts being generated:
export type Foo {
bar: Bar
}
Of course this is not valid typescript, because the type bar here is undefined.
So we could add a prefix to import Bar from a different location:
export_types!(
types: [
Foo,
]
exports: [
TypeScript(
"./export/foo.ts",
prefix: "import { Bar } from './bar.ts'",
),
]
)
This will desugar like so:
let mut emitter = TypeScript {
..Default::default()
};
let mut file = emitter
.init_destination_file(
"/export/dir/1",
"import { Bar } from './bar.ts'",
)?;
And will generate the following typescript:
import { Bar } from './bar.ts'
export type Foo = {
bar: Bar;
};
By default, the prefix will be added to the output file before the emitter.dependencies(), but this can be customized within the TypeEmitter implementation.
Through the export_types macro, it's also possible to forward initialization arguments to a type emitter.
So for instance, the TypeScript emitter supports a tab_size argument to define the output tab size.
So if the argument were specified like so in the export_types function:
...
exports: [
TypeScript(
"/export/dir/1",
tab_size: 4,
),
]
...
This would be desugared like so:
let mut emitter = TypeScript {
tab_size: 4,
..Default::default()
};
The prefix argument is not forwarded for emitter initialization, since it's passed to the call for init_destination_file.
It's also possible to define destinations in export_types with multiple emitters. This might be useful, for instance, if you want to use mutluple type emitters to output to the same file. For instance:
export_types! {
types: [
Foo,
Bar,
Baz,
],
destinations: [
(
"./ouptu_file.ts",
emitters: [
TypeScript(),
TSValidation(),
]
),
]
}
This would first emit the types Foo, Bar and Baz using the TypeScript emitter, and then using the TSValidation emitter.
How an enum is transformed depends on the type of enum.
Simple enums, which are defined as enums without associated data, will simply be transformed into typescript enums with string values.
So for examle this enum:
enum Foo {
Bar,
Baz
}
will emit the following:
export enum SimpleEnumsExample {
Foo = "Foo",
Bar = "Bar,
}
export const SimpleEnumsExampleSchema = z.enum([
SimpleEnumsExample.Foo,
SimpleEnumsExample.Bar,
])
Enums with associated data are transformed by default to unions, with the _case field used to differntiate the cases.
So for instance this enum:
#[derive(Debug, Reflect, Serialize, Deserialize)]
#[serde(tag = "_case", content = "data")]
enum Status {
Initial,
#[serde(rename_all = "camelCase")]
InProgress {
progress: f32,
should_convert: bool,
},
Complete {
urls: Vec<String>,
},
}
will be emitted as:
export enum StatusCase {
Initial = "Initial",
InProgress = "InProgress",
Complete = "Complete",
}
export const StatusCaseInitialSchema = z.object({
_case: z.literal(StatusCase.Initial),
});
export type StatusCaseInitial = z.infer<typeof StatusCaseInitialSchema>
export const StatusCaseInProgressSchema = z.object({
_case: z.literal(StatusCase.InProgress),
data: z.object({
progress: z.number(),
shouldConvert: z.bool(),
})});
export type StatusCaseInProgress = z.infer<typeof StatusCaseInProgressSchema>
export const StatusCaseCompleteSchema = z.object({
_case: z.literal(StatusCase.Complete),
data: z.object({
urls: z.array(z.string()),
})});
export type StatusCaseComplete = z.infer<typeof StatusCaseCompleteSchema>
export const StatusSchema = z.union([
StatusCaseInitialSchema,
StatusCaseInProgressSchema,
StatusCaseCompleteSchema,
]);
export type Status = z.infer<typeof StatusSchema>