Skip to content

Applying Type-driven Design in Rust and TypeScript

a group of people walking down a street next to tall buildings

Photo by Spenser Sembrat

Alexis King’s article “Parse, don’t validate” is a clear argument for doing input checking at the boundary: turn untrusted input into a more precise type once, then keep the rest of your program free of ad-hoc validators.

This post shows that pattern in:

The snippets are intentionally small; focus on the shape of the types and where parsing happens.

What “type-driven” means here

In this post, “type-driven” means:

Type-driven development in Rust

Let’s say you are writing a web API in Rust that adds a new user to your app. This API calls a register function that returns a User, something like that:

async fn register() -> User { /* */ }

What does this type User represent? From the feature spec, a user needs to provide a name, a valid email address and a phone number.

If we tackle this problem with a type-driven design approach, we could try to define beforehand what this type User should represent.

Simply, it could be something like that:

struct User {
   name: String,
   email: String,
   phone: String,
}

This is basic, and it hides important constraints. What is the maximum length that the field name can have? What about the email and phone address? Are both fields required or can they be just empty strings?

The type pushes us to think more carefully about the domain rules and it raises questions about the shape of our data.

Improving the User type

Let’s try to improve this type by defining domain types (newtypes) to represent the data we expect in each one of these fields.

For this, we will use the concept of domain modelling and some of the ideas expressed by Scott Wlaschin in his talk Domain Modeling Made Functional - Scott Wlaschin.

The specs of our feature say that name and email are required but that phone is an optional field.

So, we can refactor the User type to something like this:

struct Name(String);
struct Email(String);
struct Phone(String);

struct User {
   name: Name,
   email: Email,
   phone: Option<Phone>,
}

The Option type in Rust is an enumeration with two variants: Some(T) and None. It is used to represent an optional value and is a common way of handling the absence of a value in Rust.

In this case, we are saying that phone can be either Some(Phone) or just None.

This is already more descriptive: we can infer from the type that name and email are required, and phone is optional.

But still, there is missing some important information like how many characters a user is allowed to pass to the name field and what a valid email/phone should be.

Validating the data

We need to validate our user input before saving it to our database and this constraint will also allow us to answer the questions that we asked ourselves in the previous section.

Let’s say that the specs of our feature say that the field name can’t exceed 50 characters and the email address format should match the format indicated in the RFC 5322.

We could build some sort of validator function to deal with this. The idea is that if the data matches what we expect, our function will return true and if not, it will return false. This approach allows us to have a clear view of what our fields should accept as input and what they should not:

fn is_valid_name(name: &str) -> bool { /* */ }
fn is_valid_email(email: &str) -> bool { /* */ }

async fn register(data: FormDataFromApi) -> User {
   // We can then use our validator functions to validate the input before
   // inserting the new user in our database
   if is_valid_name(data.name) && is_valid_email(data.email) {
        // Data is valid! Insert it into the database now
        insert_user(data).await;
   } else {
        panic!("Oops, bad data!");
   }
}

There are a lot of issues with the implementation above but let’s try to focus on the validation functions.

Let’s imagine that these functions are doing some simple input validation, like if the name length is not greater than 50 characters.

If one of these checks fails, for example, the one that checks the format of an email address, it means that your previous validators were processing invalid data and now you have to roll back all the operations.

In this toy example, it doesn’t matter. In real code, validators often normalize input (trim, parse, canonicalize). Doing that work and only later discovering the input is invalid can force you to unwind partial work.

This is called shotgun parsing by the LangSec community. This topic is also vastly explored by Alexis King in her article.

If you want to learn more about this, Alexis King’s article links to the LangSec paper The Seven Turrets of Babel: A Taxonomy of LangSec Errors and How to Expunge Them, which gives a concise explanation of what shotgun parsing is.

The biggest problem here is that this kind of validation deprives the program of the ability to reject invalid input instead of processing it. It makes the program state very unpredictable, giving us the assumption that exceptions can be thrown from anywhere.

Switching to a parsing strategy

You might be wondering how parsing the data before can avoid this problem when parsing and validating are almost the same.

The point is that parsing lets you split your program into two phases: parsing and execution. Errors from invalid input can then only happen in the parsing phase.

In our previous example, we tried something similar. The code still mixes validation with execution. We can do better by parsing into domain types up front.

What we can do is use Rust’s type system to implement some kind of parse function for each of our field types and make these functions parse the data and return the correct type before we execute our database query.

Let’s change your previous example:

struct Name(String);

impl Name {
    // Parse and validate raw input into a `Name`.
    fn parse(s: String) -> Result<Self, String> {
        if s.is_empty() {
            return Err("name is empty".to_string());
        }

        // `String::len()` is bytes. Here we treat "50 characters" as 50 Unicode scalar values.
        // (If you need grapheme clusters, use a crate like `unicode-segmentation`.)
        if s.chars().count() > 50 {
            return Err(format!("{s} is too long."));
        }

        Ok(Self(s))
    }
}

// Do the same kind of implementation for the other types too
struct Email(String);
struct Phone(String);

struct User {
   name: Name,
   email: Email,
   phone: Option<Phone>,
}

// We implement the `TryFrom` trait to make
// it easier to process the conversion of our data
impl TryFrom<FormDataFromApi> for User {
    type Error = String;

    fn try_from(value: FormDataFromApi) -> Result<Self, Self::Error> {
        let name = Name::parse(value.name)?;
        let email = Email::parse(value.email)?;
        let phone = value.phone.map(Phone::parse).transpose()?;

        Ok(Self { email, name, phone })
    }
}

// Our function returns a `Result` now
async fn register(data: FormDataFromApi) -> Result<User, RegistrationError> {
   // Now the data that we will pass to
   // our insert function is of type `User`
   let new_user: User = data.try_into().map_err(RegistrationError::ValidationError)?;
   // Execute query and add user
   insert_user(&new_user).await.map_err(RegistrationError::Db)?;
   Ok(new_user)
}

With this approach, parsing happens once during TryFrom<FormDataFromApi>. If it succeeds, register and insert_user only see a valid User. If it fails, you return a validation error before touching the database.

Using this concept with TypeScript

You can apply the same boundary-parsing idea in TypeScript, but you need runtime validation to back up the types.

For this example, I’ll use Zod to keep it simple.

So, if we take our previous example and migrate it to TypeScript, it would look like that:

import { z } from "zod";

const UserSchema = z.object({
  name: z.string().min(1).max(50),
  email: z.string().email(),
  phone: z.string().regex(/^[\d-]+$/).optional(),
});

type User = z.infer<typeof UserSchema>;

async function register(data: unknown) {
  const result = UserSchema.safeParse(data);
  if (!result.success) {
    // handle validation error (result.error)
    throw result.error;
  }
  await insertUser(result.data);
}

That’s the core idea: define a schema and parse input at the boundary. parse() throws on failure; safeParse() returns a result object you can branch on.

Takeaways