Table of Contents
- Motivation
- Introduction
- Project Setup
- Building the CLI
- Working with Files
- Basic Types
- Enums
- Structs
- Tuples
- Standard Library Types
- Tests
- Building and Publishing
- Future Improvements
- Similar Open Source Tools
- Conclusion
Motivation
Why would you want to write a tool that converts Rust types to Typescript types? Aren't they entirely separate languages?
There are many reasons you might want to do so. The most common (though far from the only) case that we'll be focusing on is someone who is writing an API (like a web server) in Rust and consuming it with a Typescript frontend.
Being able to automate the sharing types defined on the backend with the frontend has a lot of benefits and and can significantly reduce development time spent re-writing the same definitions in multiple languages.
Introduction
Our very first goal will be to turn this into valid Typescript code by writing the type definitions in Rust and using our program to convert them into Typescript types:
import { NumberAlias } from "./types";
const printNum = (num: NumberAlias) => {
console.log(num);
};
printNum(10);
If you try and paste this code into a .ts
file and run it you will get an error saying NumberAlias
is undefined and the types
file you are trying to import it from does not exist. We will use the output of our program to create that file.
Type aliases work almost identically in Rust as they do in TS so this first challenge will be very simple in terms of output. Our goal is to generate the file:
types.d.ts
type NumberAlias = number;
From this Rust input file:
types.rs
type NumberAlias = i32;
Project Setup
There are two crates we will be relying on to develop out type conversion utility:
clap short for Command Line Argument Parser is the most popular Rust crate for dealing with command line arguments. We'll be using it to allow our uses to tell us the input/out filenames of the Rust and Typescript files they are using,
syn derived from the word syntax is a crate designed to convert Rust source code into a type-safe syntax tree where each token can be examined and parsed individually.
Open up your favourite terminal. Make sure you have installed Rust on your system. Fresh installations will also include Rust's package manager called Cargo which we'll need for this tutorial.
When you're ready run the following command to create the utility. I've decided to call mine typester
, but you can call yours whatever you like.
cargo new typester
This will create a Cargo.toml
file (equivalent to package.json
in the JS/TS world) and an src
directory with a main.rs
file with a demo program. Test it out with cargo run
and you should see "Hello, world!" printed to your terminal.
Next we will add the two packages (crates) that we need to write our program. Run the following command to automatically add the latest versions to your Cargo.toml
file:
cargo add clap
cargo add syn --features=full,extra-traits
The syn
crate does not include all features by default, so make sure you include the ones I've listed above. Full gives us access to the parse_file
function and extra-traits derives Debug
for syn's types to make it easier for us to log values and debug our code.
After adding your Cargo.toml
file should include the following:
[dependencies]
clap = "4.0.9"
syn = { version = "1.0.101", features = ["full", "extra-traits"] }
Your version numbers may not match exactly, though if you are seeing different major versions I would recommend you align with the versions shown above for the sake of this tutorial in case either library has introduced breaking API changes since it was written.
Building the CLI
Before diving into the parsing itself, let's take a moment to set up the command line argument parsing with the clap
crate. Fortunately for us, clap makes that extremely easy.
Here's some sample code. You can review clap's documentation for the version we are using, or read ahead for a brief overview.
use clap::{Arg, Command};
fn main() {
let matches = Command::new("Typester")
.version("0.1.0")
.author("Alex E")
.about("Convert Rust types to Typescript types")
.arg(
Arg::new("input")
.short('i')
.long("input")
.required(true)
.help("The Rust file to process (including extension)"),
)
.arg(
Arg::new("output")
.short('o')
.long("output")
.required(true)
.help("The name of the Typescript file to output (including extension)"),
)
.get_matches();
let input_filename = matches.get_one::<String>("input").expect("input required");
let output_filename = matches
.get_one::<String>("output")
.expect("output required");
dbg!(input_filename);
dbg!(output_filename);
}
The above code will provide you with a great baseline to get started. It forces the user to define both a input filename (Rust) and an output filename (Typescript).
clap
takes care of some really handy grunt work like providing a nicely formatted --help
output and enforcing required arguments.
Before testing the program out, it's good to understand a Rust CLI's (command line interface) argument structure. When compiling a release binary you can simply add the arguments as any other CLI after the name of the program, but for cargo run
you need to separate the run
and arguments with two hyphens like so:
cargo run -- --input=src/types.rs --output=types.d.ts
The output of this command with our program will be:
[src/main.rs:29] input_filename = "src/types.rs"
[src/main.rs:30] output_filename = "types.d.ts"
Furthermore, as a user who may not be aware of the required arguments can take advantage of the --help
flag:
cargo run -- --help
Convert Rust types to Typescript types
Usage: typester --input <input> --output <output>
Options:
-i, --input <input> The Rust file to process (including extension)
-o, --output <output> The name of the Typescript file to output (including extension)
-h, --help Print help information
-V, --version Print version information
That nicely formatted help output is thanks to the great clap
crate.
Working With Files
We'll use the standard library's File
, Read
and Path
to read the contents of our Rust file, and then parse it into a syn::File
where we can iterate over the contents of the file in a typesafe manner.
use clap::{Arg, Command};
use std::{
fs::File,
io::{Read, Write},
path::Path,
};
fn main() {
... // Code from previous section
let input_path = Path::new(input_filename);
let mut input_file =
File::open(input_path).expect(&format!("Unable to open file {}", input_path.display()));
let mut input_file_text = String::new();
input_file
.read_to_string(&mut input_file_text)
.expect("Unable to read file");
// This is our tokenized version of Rust file ready to process
let input_syntax: syn::File = syn::parse_file(&input_file_text).expect("Unable to parse file");
}
Since this is a simple utility meant for learning and does not need to be bulletproof and handle every potential bad file input, we'll simply use .expect()
in each scenario that doesn't conform to our requirements.
For those unfamiliar, .expect()
is the same as .unwrap()
which will panic
on a Result
of Err
or an Option
of None
, but it allows us to provide some information to the user as to where/why that failure occurred.
Now that we have created a syn:File
which the contents of our Rust file ready to parse, we can iterate over each token one at a time and start converting them into strings of text that conform to Typescript's syntax rules.
To do this we take our syn file and iterate over it with .items.iter()
:
use clap::{Arg, Command};
use std::{
fs::File,
io::{Read, Write},
path::Path,
};
fn main() {
... // Code from previous sections
// This string will store the output of the Typescript file that we will
// continuously append to as we process the Rust file
let mut output_text = String::new();
for item in input_syntax.items.iter() {
match item {
// This `Item::Type` enum variant matches our type alias
syn::Item::Type(item_type) => {
let type_text = parse_item_type(item_type);
output_text.push_str(&type_text);
}
_ => {
dbg!("Encountered an unimplemented type");
}
}
}
let mut output_file = File::create(output_filename).unwrap();
write!(output_file, "{}", output_text).expect("Failed to write to output file");
}
fn parse_item_type(item_type: &syn::ItemType) -> String {
String::from("todo")
}
At this point we are ready to test it out Create the following types.rs
file directly next to your main.rs
file:
types.rs
type NumberAlias = i32;
Now run the following command:
cargo run -- --input=src/types.rs --output=types.d.ts
(Note the "--"
before the input flag, this is a placeholder for the program name while we are building in development, make sure you include it or your program will not work properly.)
In your root of your project you should see a types.d.ts
file appear containing the text todo
:
types.d.ts
todo;
Basic Types
Now we are ready to parse the actual tokens. We'll start with the simplest one, the basic type alias type NumberAlias = i32;
.
The will match on syn::Item::Type
as we have written above and call our parse_item_type
function which we need to expand and define. Each of our parsing functions for the different types our crate handles will return a string so we can simply concatenate them all together to build the output.
... // Code from previous sections with `parse_item_type` stub removed
/// Converts a Rust item type to a Typescript type
///
/// ## Examples
///
/// **Input:** type NumberAlias = i32;
///
/// **Output:** export type NumberAlias = number;
fn parse_item_type(item_type: &syn::ItemType) -> String {
let mut output_text = String::new();
output_text.push_str("export type ");
// `ident` is the name of the type alias, `NumberAlias` from the example
output_text.push_str(&item_type.ident.to_string());
output_text.push_str(" = ");
let type_string = parse_type(&item_type.ty);
output_text.push_str(&type_string);
output_text.push_str(";");
output_text
}
The NumberAlias
in our type will be captured with the ident
value.
The i32
portion however could be any number of type values, and not necessarily a primitive. It would be (i32, i32)
for example.
For this we'll create another handler function called parse_type
:
/// Converts a Rust type into a Typescript type
///
/// ## Examples
///
/// **Input:** (i32, i32) / Option<String>
///
/// **Output:** \[number, number\] / Option<string>;
fn parse_type(syn_type: &syn::Type) -> String {
let mut output_text = String::new();
match syn_type {
// Primitive types like i32 will match Path
// We currently do not do anything with full paths
// so we take only the last() segment (the type name)
syn::Type::Path(type_path) => {
let segment = type_path.path.segments.last().unwrap();
let field_type = segment.ident.to_string();
let ts_field_type = parse_type_ident(&field_type).to_owned();
output_text.push_str(&ts_field_type);
match &segment.arguments {
// A simple type like i32 matches here as it
// does not include any arguments
syn::PathArguments::None => {}
_ => {
dbg!("Encountered an unimplemented token");
}
}
_ => {
dbg!("Encountered an unimplemented token");
}
};
output_text
}
As you an see above, we are only focusing on our simple type for the time being and ignoring all other matches.
If any type that our utility does not know how to process is encountered, the dbg!
macro will print a message along with a convenient line number so user's can easily identify which condition was hit and expand on the utility if they choose.
You may also notice that for this Path
type we have matched we are only taking the last()
token. That is because currently we are not doing anything with the other parts of the path (for example std:fs:File
), just extracting the name of the type.
The final function necessary to finish our prototype we will call parse_type_ident
which deals with the most primitive types, or simply return the name of the type if it is a custom type.
All of Rust's many different types of numbers will simply be treated as a number
when deserialized as Typescript:
/// Convert a primitive Rust ident to an equivalent Typescript type name
/// Translate primitive types to Typescript equivalent otherwise
/// returns the ident untouched
///
/// ## Examples
///
/// **Input:** i32 / Option / bool;
///
/// **Output:** number / Option / boolean;
fn parse_type_ident(ident: &str) -> &str {
match ident {
"i8" | "i16" | "i32" | "i64" | "i128" | "u8" | "u16" | "u32" | "u64" | "f32" | "f64"
| "isize" | "usize" => "number",
"str" | "String" | "char" => "string",
"bool" => "boolean",
_ => ident,
}
}
We're finally ready to try it out. Run the command again:
cargo run -- --input=src/types.rs --output=types.d.ts
With the input:
types.rs
type NumberAlias = i32;
We get the output:
types.d.ts
export type NumberAlias = number;
And that's a valid Typescript type!
Congratulations, we have our first minimum prototype for a Rust -> TS conversion utility.
Of course as you can imagine, getting complete support for serialization of all Rust's types is a monumental effort, likely requiring months or years of continued development.
But fortunately like many things, 10-20% coverage of the most common types will take care of probably 80-90% of the most common use cases.
So let's focus on getting a few more of the most common cases covered, most specifically: enums
and structs
:
Enums
This is the example enum
we'll be using to test our support:
#[serde(tag = "t", content = "c")]
enum Colour {
Red(i32),
Green(i32),
Blue(i32),
}
And the expected output we'll be trying to create:
export type Colour =
| { t: "Red"; c: number }
| { t: "Green"; c: number }
| { t: "Blue"; c: number };
You may be wondering what the #[serde(tag = "t", content = "c")]
attribute is for?
Well it turns out there are quite a few different ways that an enum can be serialized. If you'd like to learn more about them this page in the serde documentation covers all the standard ones.
The method we'll be using is called adjacently tagged, the reason for this is that it makes it very clean and easy to handle on the Typescript side by using union types to discriminate on the t
value and infer the correct type of the content c
value.
Technically these t
and c
values can be any name that you choose, and we could write our utility to parse the attribute and read what the user has set, but since we are focusing on simplicity for now we're going to operate on the assumption that the user is using t
and c
.
With that preliminary assumption in place, we can now expand our initial match in the main function to include enums, and create a new parse_item_enum
function:
... // Main function from previous sections
for item in input_syntax.items.iter() {
match item {
// This `Item::Type` enum variant matches our type alias
syn::Item::Type(item_type) => {
let type_text = parse_item_type(item_type);
output_text.push_str(&type_text);
}
syn::Item::Enum(item_enum) => {
let enum_text = parse_item_enum(item_enum);
output_text.push_str(&enum_text);
}
_ => {
dbg!("Encountered an unimplemented type");
}
}
}
let mut output_file = File::create(output_filename).unwrap();
write!(output_file, "{}", output_text).expect("Failed to write to output file");
}
Now we create the parse_item_enum
function:
/// Converts a Rust enum to a Typescript type
///
/// ## Examples
///
/// **Input:**
/// enum Colour {
/// Red(i32, i32),
/// Green(i32),
/// Blue(i32),
/// }
///
/// **Output:**
/// export type Colour =
/// | { t: "Red"; c: number }
/// | { t: "Green"; c: number }
/// | { t: "Blue"; c: number };
fn parse_item_enum(item_enum: &syn::ItemEnum) -> String {
let mut output_text = String::new();
output_text.push_str("export type");
output_text.push_str(" ");
let enum_name = item_enum.ident.to_string();
output_text.push_str(&enum_name);
output_text.push_str(" ");
output_text.push_str("=");
output_text.push_str(" ");
for variant in item_enum.variants.iter() {
// Use the pipe character for union types
// Typescript also allows it before the first type as valid syntax
// https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#union-types
output_text.push_str(" | {");
output_text.push_str(" ");
// For simplicity this implementation we are using assumes that enums will be
// using serde's "Adjacently Tagged" attribute
// #[serde(tag = "t", content = "c")]
// https://serde.rs/enum-representations.html#adjacently-tagged
// As an improvement on this implementation you could parse the attribute
// and handle the enum differently depending on which attribute the user chose
output_text.push_str("t: \"");
let variant_name = variant.ident.to_string();
output_text.push_str(&variant_name);
output_text.push_str("\" , c: ");
match &variant.fields {
syn::Fields::Named(named_fields) => {
output_text.push_str("{");
for field in named_fields.named.iter() {
if let Some(ident) = &field.ident {
output_text.push_str(&ident.to_string());
output_text.push_str(":");
let field_type = parse_type(&field.ty);
output_text.push_str(&field_type);
output_text.push_str(";");
}
}
output_text.push_str("}");
}
syn::Fields::Unnamed(unnamed_fields) => {
// Currently only support a single unnamed field: e.g the i32 in Blue(i32)
let unnamed_field = unnamed_fields.unnamed.first().unwrap();
let field_type = parse_type(&unnamed_field.ty);
output_text.push_str(&field_type);
}
syn::Fields::Unit => {
output_text.push_str("undefined");
}
}
output_text.push_str("}");
}
output_text.push_str(";");
output_text
}
I've included comments above to annotate some of the sections that may be a bit more difficult to follow.
The great thing is that we are already seeing the benefits of reuse with the previous functions we created like parse_type
.
As you continue to developer and improve this utility you'll find that creaking eac individual token parser into its own function allows for a lot of opportunities for reuse when you start handling more complex types.
With this in place we are now ready to test:
Expand our types.rs
file to include both types we now support:
types.rs
type NumberAlias = i32;
#[serde(tag = "t", content = "c")]
enum Colour {
Red(i32),
Green(i32),
Blue(i32),
}
Run the command:
cargo run -- --input=src/types.rs --output=types.d.ts
And our output will be:
types.d.ts
export type Colour =
| { t: "Red"; c: number }
| { t: "Green"; c: number }
| { t: "Blue"; c: number };
export type NumberAlias = number;
(Note that I am using a Typescript formatter, specifically prettier, on the output to make it more human friendly. The actual output, while totally valid TS, will appear on a single line)
Structs
Now let's add support for structs. Our example test input will be:
struct Person {
name: String,
age: u32,
enjoys_coffee: bool,
}
And our expected output will be:
export interface Person {
name: string;
age: number;
enjoys_coffee: boolean;
}
Update the match in the main
function to match on syn::Item::Struct
:
... // Main function from previous sections
for item in input_syntax.items.iter() {
match item {
syn::Item::Type(item_type) => {
let type_text = parse_item_type(item_type);
output_text.push_str(&type_text);
}
syn::Item::Enum(item_enum) => {
let enum_text = parse_item_enum(item_enum);
output_text.push_str(&enum_text);
}
syn::Item::Struct(item_struct) => {
let struct_text = parse_item_struct(item_struct);
output_text.push_str(&struct_text);
}
_ => {
dbg!("Encountered an unimplemented type");
}
}
}
let mut output_file = File::create(output_filename).unwrap();
write!(output_file, "{}", output_text).expect("Failed to write to output file");
}
Create the parse_item_struct
function:
/// Converts a Rust struct to a Typescript interface
///
/// ## Examples
///
/// **Input:**
/// struct Person {
/// name: String,
/// age: u32,
/// enjoys_coffee: bool,
/// }
///
/// **Output:**
/// export interface Person {
/// name: string;
/// age: number;
/// enjoys_coffee: boolean;
/// }
fn parse_item_struct(item_struct: &syn::ItemStruct) -> String {
let mut output_text = String::new();
let struct_name = item_struct.ident.to_string();
output_text.push_str("export interface");
output_text.push_str(" ");
output_text.push_str(&struct_name);
output_text.push_str(" ");
output_text.push_str("{");
match &item_struct.fields {
syn::Fields::Named(named_fields) => {
for named_field in named_fields.named.iter() {
match &named_field.ident {
Some(ident) => {
let field_name = ident.to_string();
output_text.push_str(&field_name);
output_text.push_str(":");
}
None => todo!(),
}
let field_type = parse_type(&named_field.ty);
output_text.push_str(&field_type);
output_text.push_str(";");
}
}
// For tuple structs we will serialize them as interfaces with
// fields named for the numerical index to align with serde's
// default handling of this type
syn::Fields::Unnamed(fields) => {
// Example: struct Something (i32, Anything);
// Output: export interface Something { 0: i32, 1: Anything }
for (index, field) in fields.unnamed.iter().enumerate() {
output_text.push_str(&index.to_string());
output_text.push_str(":");
output_text.push_str(&parse_type(&field.ty));
output_text.push_str(";");
}
}
syn::Fields::Unit => (),
}
output_text.push_str("}");
output_text.push_str(";");
output_text
}
Let's test it out:
types.rs
type NumberAlias = i32;
#[serde(tag = "t", content = "c")]
enum Colour {
Red(i32),
Green(i32),
Blue(i32),
}
struct Person {
name: String,
age: u32,
enjoys_coffee: bool,
}
cargo run -- --input=src/types.rs --output=types.d.ts
And our output:
types.d.ts
export type NumberAlias = number;
export type Colour =
| { t: "Red"; c: number }
| { t: "Green"; c: number }
| { t: "Blue"; c: number };
export interface Person {
name: string;
age: number;
enjoys_coffee: boolean;
}
Tuples
Tuples are a common data type in Rust that roughly correspond to array types in Typescript where each element has a specific type (so a tuple like (i32, i32)
for example would be translated to [number, number]
rather than the less specific number[]
that vectors would translate into.)
We can add simple support by expanding the match &segment.arguments
in our parse_type
function to add this addition match arm:
// in the `parse_type` function
...
// Tuple types like (i32, i32) will match here
syn::Type::Tuple(type_tuple) => {
output_text.push_str("[");
for elem in type_tuple.elems.iter() {
output_text.push_str(&parse_type(elem));
output_text.push_str(",");
}
output_text.push_str("]");
}
...
Standard Library Types
As a final update, let's add support for some of Rust's most common types in the standard library like Option
, and HashMap
.
The reason for these in particular is that they are common data types to be serialized, for example when parsed on the Javascript/Typescript side Option<T>
can be thought of as T | undefined
and HashMap<T, U>
can be deserialized as an object in the TS form of Record<T, U>
.
No doubt you can come up with more, but here are some straightforward TS mappings of some of the most common data types. We don't need to do these dynamically, we can simply prepend these pre-written types to the file output before we begin to parse:
Add the following line immediately after the variable output_text
is declared in the main function:
output_text.push_str(&create_initial_types());
Then create that create_initial_types
function as follows:
/// Initialize some Typescript equivalents of
/// core Rust types like Result, Option, etc
fn create_initial_types() -> String {
let mut output_text = String::new();
output_text
.push_str("type HashSet<T extends number | string> = Record<T, undefined>;");
output_text.push_str("type HashMap<T extends number | string, U> = Record<T, U>;");
output_text.push_str("type Vec<T> = Array<T>;");
output_text.push_str("type Option<T> = T | undefined;");
output_text.push_str("type Result<T, U> = T | U;");
output_text
}
The final version (of our incomplete program 😁) takes the following input:
types.rs
type NumberAlias = i32;
#[serde(tag = "t", content = "c")]
enum Colour {
Red(i32),
Green(i32),
Blue(i32),
}
struct Person {
name: String,
age: u32,
enjoys_coffee: bool,
}
struct ComplexType {
colour_map: HashMap<String, Colour>,
list_of_names: Vec<String>,
optional_person: Option<Person>,
}
And produces the following valid Typescript output:
types.d.ts
type HashSet<T extends number | string> = Record<T, undefined>;
type HashMap<T extends number | string, U> = Record<T, U>;
type Vec<T> = Array<T>;
type Option<T> = T | undefined;
type Result<T, U> = T | U;
export type NumberAlias = number;
export type Colour =
| { t: "Red"; c: number }
| { t: "Green"; c: number }
| { t: "Blue"; c: number };
export interface Person {
name: string;
age: number;
enjoys_coffee: boolean;
}
export interface ComplexType {
colour_map: HashMap<string, Colour>;
list_of_names: Vec<string>;
optional_person: Option<Person>;
}
So now that we have the ability to share types between Rust and Typescript!
Tests
It's always a good idea to add tests to your program/crate! Let's add some basic ones to test that we get the output for our sample types that we have been working with.
I'll start by creating a tests
in src
. I'm going to create three files total:
src/tests/type.rs
src/tests/enum.rs
src/tests/struct.rs
These files will just have the three basic sample types we have been working with:
src/tests/type.rs
type NumberAlias = i32;
src/tests/enum.rs
#[serde(tag = "t", content = "c")]
enum Colour {
Red(i32),
Green(i32),
Blue(i32),
}
src/tests/struct.rs
struct Person {
name: String,
age: u32,
enjoys_coffee: bool,
}
Next I'll write some simple test that read in these files and assert that the output matches the Typescript output that we expect, so that we can safely expand our type generating utility in the future and be able to know immediately by running our tests that we haven't broken any existing functionality:
For now I'm simply going to add this testing module down at the bottom of main.rs
below the program itself and I'll use use super::*
so I have access to all the same modules that the main program does:
main.rs
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn handles_type_alias() {
let mut input_file = File::open("./src/tests/type.rs").unwrap();
let mut input_file_text = String::new();
input_file.read_to_string(&mut input_file_text).unwrap();
let input_syntax: syn::File =
syn::parse_file(&input_file_text).expect("Unable to parse file");
let typescript_types = parse_syn_file(input_syntax);
assert_eq!(r#"export type NumberAlias = number;"#, &typescript_types);
}
#[test]
fn handles_struct() {
let mut input_file = File::open("./src/tests/struct.rs").unwrap();
let mut input_file_text = String::new();
input_file.read_to_string(&mut input_file_text).unwrap();
let input_syntax: syn::File =
syn::parse_file(&input_file_text).expect("Unable to parse file");
let typescript_types = parse_syn_file(input_syntax);
assert_eq!(
r#"export interface Person {name:string;age:number;enjoys_coffee:boolean;};"#,
&typescript_types
);
}
#[test]
fn handles_enum() {
let mut input_file = File::open("./src/tests/enum.rs").unwrap();
let mut input_file_text = String::new();
input_file.read_to_string(&mut input_file_text).unwrap();
let input_syntax: syn::File =
syn::parse_file(&input_file_text).expect("Unable to parse file");
let typescript_types = parse_syn_file(input_syntax);
assert_eq!(
r#"export type Colour = | { t: "Red" , c: number} | { t: "Green" , c: number} | { t: "Blue" , c: number};"#,
&typescript_types
);
}
}
When we run the following command on the terminal:
cargo test
We would see the output:
running 3 tests
test tests::handles_type_alias ... ok
test tests::handles_enum ... ok
test tests::handles_struct ... ok
Remember to keep your tests updated as you expand your utility!
Building and Publishing
There are a few different ways we can use and share our program that we'll go over quickly.
The first is with the command we have been using to do build and run a development version:
cargo run -- --input=src/types.rs --output=types.d.ts
We can also build a release version with all the optimizations using:
cargo run --release
Then you'll find the typester
binary in the target/release
folder. You can now place it anywhere on your machine and run it from the command line by navigating to the directory and invoking:
./typester --input=src/types.rs --output=types.d.ts
The last method if you want to share your utility with others, or use it on other machines is to publish it to crates.io.
You'll first need to upload your project to Github and make a crates.io account if you don't have one already. This tutorial will take you through all the steps you need to publish your crate.
Now you can install your CLI locally with cargo install YOUR_CRATE_NAME
.
You can see here I've published this utility to crates.io and now I can install it on any machine that has cargo installed with:
cargo install typester
Which installs by default in your $HOME/.cargo
directory. Now you can use the utility on any directory on your machine!
Future Improvements
There's plenty of options for improving this crate you can pursue beyond just improving support for additional types within Rust's ecosystem.
Here's a few examples:
Target an entire project directory rather than just a single file with the walkdir crate
Capture doc comments for your types and convert them to jsdoc comments so your frontend dev team knows exactly how to use your types
Custom attributes. We've already talked about parsing serde's attributes, what what about custom ones specifically for your crate like the ability to
#[ignore]
types that aren't intended for frontend consumption?Camel case. Have your CLI watch for serde's rename attribute and transform your struct fields into Typescript's standard of
camelCase
from Rust'ssnake_case
Generics! Can you include support for them. I don't even know where to start with that one, but it would probably be a great challenge.
I'm sure you can come up with other great ideas too, feel free to comment on this post with your own!
Similar Open Source Tools
If you are actually looking for a more robust version of this kind of tool and not interest in writing it yourself, there are a number of very similar and more feature rich versions of this utility out there already in the wild:
Typeshare by 1Password, the original inspiration for this blog post. It's available on crates.io.
tsync by Wulf created as part of the awesome Create Rust App utility for setting up fullstack Rust apps. It's available on crates.io
Conclusion
I hope you learned something new about how Rust itself can be parsed with crates like syn
and different kinds of helpful utilities can be built out of it.
There's no reason you can't add support for other languages you use too! I simply chose Typescript as it's the one I'm most familiar with.
In a follow up blog post, we'll take a look at how to actually use this utility in a real fullstack application with an
Axum backend in Rust, and React with Vite for the frontend.
Top comments (2)
Cool! I'm just wondering though, for enums why aren't you using the TypeScript enums?
Here is a video explaining why they are considered harmful
youtu.be/jjMbPt_H3RQ