DEV Community

Cover image for How to Build a Rust CLI Tool to Generate Typescript Types from Rust
Alex Eagleson
Alex Eagleson

Posted on

How to Build a Rust CLI Tool to Generate Typescript Types from Rust

Table of Contents

  1. Motivation
  2. Introduction
  3. Project Setup
  4. Building the CLI
  5. Working with Files
  6. Basic Types
  7. Enums
  8. Structs
  9. Tuples
  10. Standard Library Types
  11. Tests
  12. Building and Publishing
  13. Future Improvements
  14. Similar Open Source Tools
  15. 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);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

From this Rust input file:

types.rs

type NumberAlias = i32;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"] }
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Furthermore, as a user who may not be aware of the required arguments can take advantage of the --help flag:

cargo run -- --help
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Now run the following command:

cargo run -- --input=src/types.rs --output=types.d.ts
Enter fullscreen mode Exit fullscreen mode

(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;
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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,
    }
}
Enter fullscreen mode Exit fullscreen mode

We're finally ready to try it out. Run the command again:

cargo run -- --input=src/types.rs --output=types.d.ts
Enter fullscreen mode Exit fullscreen mode

With the input:

types.rs

type NumberAlias = i32;
Enter fullscreen mode Exit fullscreen mode

We get the output:

types.d.ts

export type NumberAlias = number;
Enter fullscreen mode Exit fullscreen mode

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),
}
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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),
}
Enter fullscreen mode Exit fullscreen mode

Run the command:

cargo run -- --input=src/types.rs --output=types.d.ts
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

(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,
}
Enter fullscreen mode Exit fullscreen mode

And our expected output will be:

export interface Person {
  name: string;
  age: number;
  enjoys_coffee: boolean;
}
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode
cargo run -- --input=src/types.rs --output=types.d.ts
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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("]");
    }
    ...
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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>,
}
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

These files will just have the three basic sample types we have been working with:

src/tests/type.rs

type NumberAlias = i32;
Enter fullscreen mode Exit fullscreen mode

src/tests/enum.rs

#[serde(tag = "t", content = "c")]
enum Colour {
    Red(i32),
    Green(i32),
    Blue(i32),
}
Enter fullscreen mode Exit fullscreen mode

src/tests/struct.rs

struct Person {
    name: String,
    age: u32,
    enjoys_coffee: bool,
}
Enter fullscreen mode Exit fullscreen mode

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
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

When we run the following command on the terminal:

cargo test
Enter fullscreen mode Exit fullscreen mode

We would see the output:

running 3 tests
test tests::handles_type_alias ... ok
test tests::handles_enum ... ok
test tests::handles_struct ... ok
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

We can also build a release version with all the optimizations using:

cargo run --release
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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's snake_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:

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)

Collapse
 
codingjlu profile image
codingjlu

Cool! I'm just wondering though, for enums why aren't you using the TypeScript enums?

Collapse
 
john98zakaria profile image
John Sorial • Edited

Here is a video explaining why they are considered harmful
youtu.be/jjMbPt_H3RQ