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

A String can hold a name, an email address, or input that the application should reject. The type doesn’t distinguish between them. If business logic accepts raw strings, their type can’t tell it which domain checks have already passed.

Alexis King’s article “Parse, don’t validate” argues for doing that work once, at the boundary. Turn input from HTTP, a CLI, or a file into a more precise type before passing it to the rest of the program. That removes the need for ad hoc validators scattered through the business logic.

That’s what “type-driven” means here: domain types such as Name, Email, and Phone record the rules, and business logic works with a User whose fields have already been checked.

Type-driven development in Rust

For a small example, take a web API in Rust that adds a user to an app. The snippets focus on types and parsing; the HTTP and database implementations are outside this example.

The feature spec describes a user through a name, a valid email address, and a phone number. With a type-driven approach, we start by deciding what User should represent. A first attempt might use strings for all three fields:

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

How long can name be? What counts as a valid email address or phone number? Are those fields required, or can they be empty strings? These are domain rules, and this User definition doesn’t encode any of them.

Improving the User type

We can give each field a domain type using a newtype. Scott Wlaschin describes this approach with wrapper types and constructors in Designing with types, and in his talk Domain Modeling Made Functional.

The spec requires name and email, but makes phone optional. We can express that directly in User:

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

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

Rust’s Option<T> has two variants, Some(T) and None. Here, phone can contain a Phone or be absent, while name and email remain required. How missing or null JSON fields become None depends on the deserializer; Option alone doesn’t define a wire format.

We’ve made optionality explicit. The remaining constraints, such as the length of name and the format of an email address or phone number, still need to be enforced.

Validating the data

Before saving input, we need an explicit acceptance policy. For this example, a name must be nonempty and contain at most 50 Unicode scalar values. Email syntax and phone formatting need their own rules; passing a format check doesn’t establish that either address belongs to the user.

One approach is to write Boolean validators and run all of them before the database call. This is a control-flow sketch: the validator bodies, insert_raw_user, and application error types are omitted.

struct FormDataFromApi {
    name: String,
    email: String,
    phone: Option<String>,
}

async fn register(data: FormDataFromApi) -> Result<User, RegistrationError> {
    let valid = is_valid_name(&data.name)
        && is_valid_email(&data.email)
        && data.phone.as_deref().map_or(true, is_valid_phone);

    if !valid {
        return Err(RegistrationError::ValidationError("invalid input".into()));
    }

    insert_raw_user(data).await.map_err(RegistrationError::Db)
}

With side-effect-free validators, this already rejects bad input before the write. A failed email check doesn’t require rolling back the preceding name check. The weakness is that data still has its raw type: another caller can skip the checks and pass the same type to insert_raw_user.

Shotgun parsing is a different, related problem: input checks are scattered through processing, so the program can act on input before discovering that another part is malformed. The Seven Turrets of Babel defines it this way, as quoted and discussed by King. Boolean return values don’t cause that failure; interleaving checks with effects does.

Switching to a parsing strategy

In King’s use of the terms, parsing and validation can perform the same checks. The difference is what survives success: a Boolean gives us true, while a parser returns a value whose type records what was checked.

Start with a smart constructor for Name. Rust’s chars() iterator counts Unicode scalar values, whereas len() counts bytes. Neither counts user-perceived characters: e followed by a combining accent counts as two scalar values. This example enforces only the stated nonempty/length policy, without trimming or normalization.

mod name {
    pub struct Name(String);

    impl Name {
        pub fn parse(s: String) -> Result<Self, String> {
            if s.is_empty() {
                return Err("name is empty".into());
            }
            if s.chars().count() > 50 {
                return Err("name exceeds 50 Unicode scalar values".into());
            }
            Ok(Self(s))
        }

        pub fn as_str(&self) -> &str {
            &self.0
        }
    }
}

use crate::name::Name;

The module is part of the guarantee. A newtype alone doesn’t prevent unchecked construction: private fields are accessible inside their defining module and its descendants. Outside name, callers can use Name::parse and read through as_str, but can’t construct Name(raw) or mutate its inner string. Keep that module small and preserve the invariant in every constructor and mutator.

We can then assemble a User with TryFrom<FormDataFromApi>. This next block is an integration sketch, using the raw form above. It assumes Email and Phone have equivalent private-field APIs, with parse(String) -> Result<Self, String> implementations for the application’s chosen rules. The database helper and error definitions are still omitted.

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

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 })
    }
}

async fn register(data: FormDataFromApi) -> Result<User, RegistrationError> {
    let new_user = User::try_from(data).map_err(RegistrationError::ValidationError)?;
    insert_user(&new_user).await.map_err(RegistrationError::Db)?;
    Ok(new_user)
}

For phone, map(Phone::parse) produces an Option<Result<Phone, String>>. transpose() turns that into Result<Option<Phone>, String>: absence succeeds as None, a present valid value becomes Some(Phone), and a present invalid value returns an error.

The new insert_user takes &User and returns Result<(), DbError>. A parsing failure returns before calling it. Success means the fields satisfy the rules the constructors actually checked. It doesn’t prove that an email is unique in the database, that a user is authorized, or that the database is available. Those checks and database constraints still belong where the relevant state is known.

Using this concept with TypeScript

TypeScript types don’t validate incoming data at runtime. This example uses Zod 4.5 to check input and z.infer<typeof Schema> to derive the output type. The database function is declared only to show its contract; its implementation is omitted.

import { z } from "zod";

const UserSchema = z.object({
  name: z.string().min(1).max(50),
  email: z.email(),
  phone: z.string().trim().regex(/^[0-9]+(?:-[0-9]+)*$/).optional(),
}).brand<"User">().readonly();

type User = z.infer<typeof UserSchema>;

declare function insertUser(user: User): Promise<void>;

async function register(data: unknown): Promise<User> {
  const result = UserSchema.safeParse(data);
  if (!result.success) {
    throw result.error;
  }
  await insertUser(result.data);
  return result.data;
}

safeParse() returns a discriminated union containing either parsed data or a validation error. We branch on success before calling the database. parse() performs the check too, but throws on validation failure. Neither catches a later database error.

The schema’s limits are deliberate:

An ordinary inferred object type would still let typed callers pass unchecked strings. .brand<"User">() distinguishes parsed output from a plain object, and .readonly() makes these fields readonly and freezes the returned object. Type assertions can bypass TypeScript’s checks, so this still needs a runtime parser at the boundary.

The useful guarantee is specific: core logic receives values that passed the encoded input rules. Smart constructors and module privacy establish that contract in Rust; the Zod parser and branded output make it explicit in TypeScript. Neither design removes errors that depend on later operations or changing external state.