Generate TypeScript Bindings for Rust

Rust is a really great language when it comes to developing back-end web applications, it offers type safety, and extreme performance. This makes Rust a desirable choice to develop back-end web applications.

However, all applications need JavaScript or TypeScript on the client or the front-end, TypeScript adds type safety for JavaScript, makes it easier to refactor and catch errors early in the development process. However, if your back-end is written in Rust, you'll need a way to communicate between the front-end and the back-end.

One way to do that is to write TypeScript interfaces in your front-end manually based on how the Rust back-end sends back the response.

However, this is not very efficient, you'll have to spend a lot of time making sure the types from the backend and front-end match exactly, there will be a big room for error, and will be time consuming to make sure all types match exactly to the backend responses.

In this blog post, we'll use a Rust library crate ts-bind, this crate will help us generate TypeScript bindings automatically based on our Rust code so we don't need to manually write code and sync them, and all of our types can be in the same place.

Using ts-bind

ts-bind provides a derive macro TsBind that can be used on top of Rust structs and it will automatically find the corresponding TypeScript type that matches the JSON output of the Rust struct.

Install the crate:

cargo new rust-typescript
cd rust-typescript
cargo add ts-bind

Learn Rust by Practice

Master Rust through hands-on coding exercises and real-world examples.

How to use ts-bind

Using the library is pretty easy, you just need to add the TsBind derive macro on top of your Rust struct and it will automatically generate the TypeScript type for you.

Let's create a simple struct and use the TsBind derive macro:

use ts_bind::TsBind;
 
#[derive(TsBind)]
struct User {
    name: String,
    age: u32,
}

Now, we don't need to do anything at all, the TsBind derive macro will automatically generate the TypeScript type for us in a directory named bindings/ in the root of our project. If you don't see it make sure you save the file and run cargo build.

The generated TypeScript file will look like this:

export interface User {
  name: string
  age: number
}

More complex types

The TsBind derive macro can also handle more complex types, like nested structs and more complex types. Let's update the struct above and add a few more fields:

use ts_bind::TsBind;
 
#[derive(TsBind)]
struct User {
    id: i32,
    name: String,
    age: u32,
    address: Address,
}
 
#[derive(TsBind)]
struct Address {
    id: i32,
    street: String,
    city: String,
    country: String,
}

The generated TypeScript file will look like this:

// bindings/User.ts
import type { Address } from "./Address"
 
export interface User {
  id: number
  name: string
  age: number
  address: Address
}
 
// bindings/Address.ts
export interface Address {
  id: number
  street: string
  city: string
  country: string
}

ts-bind will automatically generate the TypeScript types for the nested struct as well and it will import the nested struct in the main struct ensuring complete type-safety.

Optional fields and Arrays

TypeScript types can be optional, and can also be arrays, ts-bind can handle that as well, let's update the User struct to include an optional field and an array:

use ts_bind::TsBind;
 
#[derive(TsBind)]
struct User {
    id: i32,
    name: String,
    age: u32,
    address: Address,
    phone_number: Option<String>,
    friends: Vec<User>,
}

The generated TypeScript file will look like this:

// bindings/User.ts
import type { Address } from "./Address"
 
export interface User {
  id: number
  name: string
  age: number
  address: Address
  phone_number: string | null
  friends: User[]
}
 
// bindings/Address.ts
// address will be the same as before

Features

ts-bind provides a few features you can tweak to modify the output TypeScript code to your liking, you can find the features documentation to read more about it, but we'll go over a few of them here.

Renaming the generated TypeScript file

The TsBind macro has an attribute called ts_bind() which we can use on the struct itself and even the fields to do modifications on them, let's say we have a struct called BlogPost and we want to rename the generated TypeScript file to PostType:

use ts_bind::TsBind;
 
#[derive(TsBind)]
#[ts_bind(rename = "BlogType")]
struct BlogPost {
    id: i32,
    title: String,
    content: String,
}

The generated TypeScript file will be in bindings/BlogType.ts:

export interface BlogType {
  id: number
  title: string
  content: string
}

Renaming to a specific casing

It's fairly common in TypeScript and JavaScript to use camelCase instead of the snake_case we use here in Rust, so it would be a good idea to have an output of camelCase while sticking to our Rust convention with Rust's snake_case.

We can use the ts_bind(rename_all = "..") attribute to do that.

use ts_bind::TsBind;
 
#[derive(TsBind)]
#[ts_bind(rename_all = "camelCase")]
pub struct User {
  id: i32,
  post_ids: Vec<String>,
  address_id: Option<String>
}

Generated TypeScript

export interface User {
  id: number
  postIds: string[]
  addressId: string | null
}

You can also provide PascalCase, snake_case, UPPERCASE, lowercase if needed to modify the fields to the corresponding casing.

Custom export directory

You can also have your own custom directory by providing the ts_bind(export = "...") attribute and provide a custom directory.

use ts_bind::TsBind;
 
#[derive(TsBind)]
#[ts_bind(export = "blogs")]
struct BlogPost {
    id: i32,
    title: String,
    #[ts_bind(rename = "post_content")]
    content: String,
}

Generated TypeScript code

// blogs/BlogPost.ts
export interface BlogPost {
  id: number
  title: string
  post_content: string
}

Renaming fields

You can also rename individual fields, by using the ts_bind(rename = "...") attribute on the specific fields.

use ts_bind::TsBind;
 
#[derive(TsBind)]
#[ts_bind(rename = "BlogType")]
struct BlogPost {
    id: i32,
    title: String,
    #[ts_bind(rename = "post_content")]
    content: String,
}

Generated TypeScript code

export interface BlogType {
  id: number
  title: string
  post_content: string
}

Skipping fields

Sometimes you want to omit a field in the output TypeScript file, for that you can use the ts_bind(skip) attribute on the field you want to omit.

use ts_bind::TsBind;
 
#[derive(TsBind)]
struct BlogPost {
    id: i32,
    title: String,
    #[ts_bind(skip)]
    content: String,
}

Generated TypeScript file:

export interface BlogPost {
  id: number
  title: string
}

Building an Axum backend

Now that we've had an overview on how to generate TypeScript bindings for our Rust code, let's simulate a real-life example by creating an Axum backend and generating TypeScript bindings for the routes that we create.

Axum is a Rust backend framework that is built on top of Tokio, it's very fast and efficient and has a lot of useful features.

Let's create a new Rust application named rust-typescript:

cargo new rust-typescript

Adding dependencies

We need a few dependencies to make our Axum backend work:

  • axum - The main Axum library
  • tokio - The async runtime that we'll need to run our Axum application
  • serde - The serialization and deserialization library for Rust
  • ts-bind - The library we'll use to generate TypeScript bindings

Run the following commands to add the dependencies:

cargo add axum ts-bind
cargo add tokio --features full
cargo add serde --features derive

Here's how your Cargo.toml should look like, your version numbers might be different:

[package]
name = "rust-typescript"
version = "0.1.0"
edition = "2021"
 
[dependencies]
axum = "0.7.5"
serde = { version = "1.0.205", features = ["derive"] }
tokio = { version = "1.39.2", features = ["full"] }
ts-bind = "0.1.7"

Let's create a simple Axum application and then build on top of it:

use axum::Router;
 
#[tokio::main]
async fn main() {
    let app = Router::new();
 
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

We'll create a simple route that returns a struct of User.

use axum::{http::StatusCode, routing::get, Json, Router};
use serde::Serialize;
 
#[tokio::main]
async fn main() {
    let app = Router::new().route("/", get(get_user));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
 
#[derive(Serialize)]
struct User {
    id: u64,
    username: String,
    email: String,
}
 
async fn get_user() -> (StatusCode, Json<User>) {
    let user = User {
        id: 1337,
        username: "Jon Doe".to_string(),
        email: "[email protected]".to_string(),
    };
 
    (StatusCode::OK, Json(user))
}

If we go to http://localhost:3000 we should see the JSON response of the User struct.

Axum response Axum response

Great, now the API works, let's generate TypeScript bindings for the User struct.

Generating TypeScript bindings

We'll use the ts-bind crate to generate TypeScript bindings for the User struct, we'll add the TsBind derive macro on top of the User struct and then we'll see the generated TypeScript file.

use ts_bind::TsBind;
 
...
 
#[derive(Serialize)]
#[derive(TsBind)]
struct User {
    id: u64,
    username: String,
    email: String,
}

This will automatically create you a typescript file in the bindings/ directory with the name User.ts:

export interface User {
  id: number
  username: string
  email: string
}

Using the TypeScript bindings

Even though this blog post isn't necessarily about how to use TypeScript, I'll show you how you can use the generated TypeScript bindings in your front-end.

The best way to do it is having a TypeScript Monorepo where you can have your front-end and back-end in the same repository, you can then make your Rust backend another package by adding a package.json to it and then you can import the TypeScript bindings in your front-end, this goes beyond the scope of this blog post, but we'll assume that you already know that and have set up your TypeScript Monorepo and installed your Rust backend as a package.

After you added your Rust backend as a package in your TypeScript Monorepo, you can import the TypeScript bindings in your front-end like this:

import { User } from "rust-typescript/bindings/User"
 
const fetchUser = async (): Promise<User> => {
  const response = await fetch("http://localhost:3000")
  return response.json() as Promise<User>
}
 
const run = async () => {
  const user = await fetchUser()
 
  const id = user.id
  const username = user.username
  const email = user.email
 
  console.table({ id, username, email })
}
 
run()

This will give you type safety in your front-end, and you can be sure that the response from the backend is exactly what you expect it to be.

Front-end response Front-end response

If we try to access a field that doesn't exist in the User struct, TypeScript will throw an error:

TypeScript error TypeScript error

Property 'address' does not exist on type 'User', this is the type-safety that we want. Now if we were to add an address field in the backend response, the changes will instantly be reflected in the TypeScript project as well.

#[derive(Serialize)]
#[derive(TsBind)]
struct User {
    id: u64,
    username: String,
    email: String,
    address: String
}
 
async fn get_user() -> (StatusCode, Json<User>) {
    let user = User {
        id: 1337,
        username: "Jon Doe".to_string(),
        email: "[email protected]".to_string(),
        address: "1234 Street".to_string(),
    };
 
    (StatusCode::OK, Json(user))
}

TypeScript error fix TypeScript error fix

The error goes away instantly, giving you a seamless developer experience.

Conclusion

Using ts-bind to generate TypeScript bindings for Rust code offers several key benefits, you can automatically synchronize your TypeScript types with your Rust structs, ensuring type consistency between your backend and frontend, catching potential issues early in the development process, and making it easier to keep your codebase in sync.

This also gives your an enhanced developer experience, with proper type definitions, developers don't have to go back and forth to look at the responses that the backend sends, they can just look at the TypeScript bindings and see what the response is.

Type-safety also helps in easier refactoring, it's common for projects to need refactoring especially if they grow quickly, with this kind of automatic type synchronization, refactoring becomes easier and less error-prone.

Thank you for reading this blog post 🤗, we hope you found it useful. If you enjoyed this one, you might also enjoy our Rust exercises, give it a go and see how you do!

Learn Rust by Practice

Master Rust through hands-on coding exercises and real-world examples.

Check out our blog

Discover more insightful articles and stay up-to-date with the latest trends.

Subscribe to our newsletter

Get the latest updates and exclusive content delivered straight to your inbox.