A practical guide to type-safe data handling inspired by Alexis King's original post


The Core Problem

When you validate data, you check if it's correct, then throw away that information:

function validateEmail(email: string): boolean {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}

function sendWelcomeEmail(email: string) {
  if (!validateEmail(email)) {
    throw new Error('Invalid email');
  }
  // Send email...
  // But wait - is email STILL valid here?
  // TypeScript doesn't know we just validated it!
}

function updateUserEmail(userId: number, email: string) {
  if (!validateEmail(email)) {  // Validating AGAIN
    throw new Error('Invalid email');
  }
  // Save to database...
}

Problems:

  • Validation logic scattered everywhere
  • Must validate repeatedly throughout code
  • No guarantee data is actually valid
  • Easy to forget to validate
  • TypeScript can't help us - it just sees string

The Solution: Parse Instead

When you parse data, you transform it into a type that guarantees validity:

// A branded type - can only be created by our parser
type Email = string & { readonly __brand: 'Email' };

function parseEmail(input: string): Email {
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input)) {
    throw new Error('Invalid email format');
  }
  return input as Email;
}

function sendWelcomeEmail(email: Email) {
  // No validation needed! Type guarantees it's valid
  console.log(`Sending to ${email}`);
}

function updateUserEmail(userId: number, email: Email) {
  // No validation needed here either!
  console.log(`Updating user ${userId} with ${email}`);
}

// Usage
try {
  const email = parseEmail(userInput); // Parse ONCE at the boundary
  sendWelcomeEmail(email);
  updateUserEmail(123, email);
} catch (error) {
  console.error('Invalid email provided');
}

Benefits:

  • Validate once at the system boundary
  • TypeScript enforces you can't create invalid data
  • Functions accepting Email don't need validation
  • Compiler prevents mixing validated and unvalidated data

Example 1: Non-Empty Arrays

The Validation Way (Bad)

function getFirst(array: string[]): string {
  if (array.length === 0) {
    throw new Error('Array is empty');
  }
  return array[0];
}

function getLast(array: string[]): string {
  if (array.length === 0) {  // Check AGAIN
    throw new Error('Array is empty');
  }
  return array[array.length - 1];
}

function processNames(names: string[]) {
  if (names.length === 0) {  // Check AGAIN
    throw new Error('Need at least one name');
  }

  const first = getFirst(names);
  const last = getLast(names);
  console.log(`From ${first} to ${last}`);
}

The Parsing Way (Good)

// Type that CANNOT be empty
type NonEmptyArray<T> = [T, ...T[]];

function parseNonEmpty<T>(array: T[]): NonEmptyArray<T> {
  if (array.length === 0) {
    throw new Error('Array cannot be empty');
  }
  return array as NonEmptyArray<T>;
}

function getFirst<T>(array: NonEmptyArray<T>): T {
  return array[0];  // No check needed! Always has first element
}

function getLast<T>(array: NonEmptyArray<T>): T {
  return array[array.length - 1];  // No check needed!
}

function processNames(names: NonEmptyArray<string>) {
  // No checks needed! Type guarantees it's not empty
  const first = getFirst(names);
  const last = getLast(names);
  console.log(`From ${first} to ${last}`);
}

// Usage
try {
  const names = parseNonEmpty(['Alice', 'Bob', 'Charlie']);
  processNames(names);  // ✅ Works!

  const empty = parseNonEmpty([]);  // ❌ Throws at parse time
} catch (error) {
  console.error('Cannot process empty list');
}

Example 2: User Registration

The Validation Way (Bad)

interface UserInput {
  email: string;
  age: number;
  username: string;
}

function validateUserInput(input: UserInput): boolean {
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.email)) {
    return false;
  }
  if (input.age < 18 || input.age > 120) {
    return false;
  }
  if (input.username.length < 3) {
    return false;
  }
  return true;
}

function registerUser(input: UserInput) {
  if (!validateUserInput(input)) {
    throw new Error('Invalid user data');
  }
  // But input is still just UserInput...
  // TypeScript doesn't know it's been validated!
  saveToDatabase(input);
  sendWelcomeEmail(input.email);  // Is this email valid? Who knows!
}

The Parsing Way (Good)

// Raw input from user
interface UserInput {
  email: string;
  age: number;
  username: string;
}

// Validated types
type Email = string & { readonly __brand: 'Email' };
type Age = number & { readonly __brand: 'Age' };
type Username = string & { readonly __brand: 'Username' };

// Parsed user type - guaranteed valid
interface ValidatedUser {
  email: Email;
  age: Age;
  username: Username;
}

// Parsing functions
function parseEmail(input: string): Email {
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input)) {
    throw new Error('Invalid email format');
  }
  return input as Email;
}

function parseAge(input: number): Age {
  if (input < 18 || input > 120) {
    throw new Error('Age must be between 18 and 120');
  }
  return input as Age;
}

function parseUsername(input: string): Username {
  if (input.length < 3) {
    throw new Error('Username must be at least 3 characters');
  }
  return input as Username;
}

// Main parser combines all parsers
function parseUserInput(input: UserInput): ValidatedUser {
  return {
    email: parseEmail(input.email),
    age: parseAge(input.age),
    username: parseUsername(input.username),
  };
}

// These functions only accept validated data
function saveToDatabase(user: ValidatedUser) {
  console.log('Saving user:', user);
}

function sendWelcomeEmail(email: Email) {
  console.log('Sending email to:', email);
}

// Main registration - parse at the boundary
function registerUser(input: UserInput) {
  const validUser = parseUserInput(input);  // Parse ONCE
  saveToDatabase(validUser);  // ✅ Guaranteed valid
  sendWelcomeEmail(validUser.email);  // ✅ Guaranteed valid email
}

// Usage
try {
  registerUser({
    email: 'user@example.com',
    age: 25,
    username: 'john_doe'
  });
} catch (error) {
  console.error('Registration failed:', error.message);
}

Example 3: Better Error Handling with Result Types

Instead of throwing exceptions, return a Result type:

type Result<T, E> =
  | { success: true; value: T }
  | { success: false; error: E };

function ok<T>(value: T): Result<T, never> {
  return { success: true, value };
}

function err<E>(error: E): Result<never, E> {
  return { success: false, error };
}

// Parser that returns Result instead of throwing
function parseEmailSafe(input: string): Result<Email, string> {
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input)) {
    return err('Invalid email format');
  }
  return ok(input as Email);
}

function parseAgeSafe(input: number): Result<Age, string> {
  if (input < 18 || input > 120) {
    return err('Age must be between 18 and 120');
  }
  return ok(input as Age);
}

function parseUsernameSafe(input: string): Result<Username, string> {
  if (input.length < 3) {
    return err('Username must be at least 3 characters');
  }
  return ok(input as Username);
}

function parseUserInputSafe(input: UserInput): Result<ValidatedUser, string[]> {
  const errors: string[] = [];

  const emailResult = parseEmailSafe(input.email);
  const ageResult = parseAgeSafe(input.age);
  const usernameResult = parseUsernameSafe(input.username);

  if (!emailResult.success) errors.push(emailResult.error);
  if (!ageResult.success) errors.push(ageResult.error);
  if (!usernameResult.success) errors.push(usernameResult.error);

  if (errors.length > 0) {
    return err(errors);
  }

  return ok({
    email: emailResult.value,
    age: ageResult.value,
    username: usernameResult.value,
  });
}

// Usage with Result
const result = parseUserInputSafe({
  email: 'invalid-email',
  age: 15,
  username: 'ab'
});

if (result.success) {
  registerUser(result.value);
} else {
  console.error('Validation errors:', result.error);
  // Output: ['Invalid email format', 'Age must be between 18 and 120', 'Username must be at least 3 characters']
}

Example 4: API Response Parsing

A very common use case - parsing data from an API:

// Raw API response (untrusted)
interface ApiUserResponse {
  id: unknown;
  email: unknown;
  name: unknown;
  age: unknown;
}

// Our validated domain types
type UserId = number & { readonly __brand: 'UserId' };

interface User {
  id: UserId;
  email: Email;
  name: string;
  age: Age;
}

function parseUserId(input: unknown): UserId {
  if (typeof input !== 'number' || input <= 0) {
    throw new Error('Invalid user ID');
  }
  return input as UserId;
}

function parseString(input: unknown, fieldName: string): string {
  if (typeof input !== 'string' || input.trim().length === 0) {
    throw new Error(`${fieldName} must be a non-empty string`);
  }
  return input.trim();
}

function parseNumber(input: unknown, fieldName: string): number {
  if (typeof input !== 'number') {
    throw new Error(`${fieldName} must be a number`);
  }
  return input;
}

function parseApiUser(response: ApiUserResponse): User {
  return {
    id: parseUserId(response.id),
    email: parseEmail(parseString(response.email, 'email')),
    name: parseString(response.name, 'name'),
    age: parseAge(parseNumber(response.age, 'age')),
  };
}

// Usage
async function fetchUser(userId: number): Promise<User> {
  const response = await fetch(`/api/users/${userId}`);
  const data = await response.json() as ApiUserResponse;

  // Parse at the boundary - before data enters our system
  return parseApiUser(data);
}

// Now the rest of your app works with validated User objects
async function displayUserProfile(userId: number) {
  const user = await fetchUser(userId);
  // user is guaranteed to be valid!
  console.log(`Name: ${user.name}`);
  console.log(`Email: ${user.email}`);
  console.log(`Age: ${user.age}`);
}

Using Zod - A Popular Parsing Library

Instead of writing parsers manually, use Zod:

import { z } from 'zod';

// Define schema
const EmailSchema = z.string().email();
const AgeSchema = z.number().int().min(18).max(120);
const UsernameSchema = z.string().min(3);

const UserSchema = z.object({
  email: EmailSchema,
  age: AgeSchema,
  username: UsernameSchema,
});

// TypeScript type is automatically inferred!
type ValidatedUser = z.infer<typeof UserSchema>;

// Parse user input
function registerUser(input: unknown) {
  try {
    const user = UserSchema.parse(input);
    // user is now ValidatedUser - guaranteed valid!
    saveToDatabase(user);
    sendWelcomeEmail(user.email);
  } catch (error) {
    if (error instanceof z.ZodError) {
      console.error('Validation errors:', error.errors);
    }
  }
}

// Or use safeParse for Result-style handling
function registerUserSafe(input: unknown) {
  const result = UserSchema.safeParse(input);

  if (result.success) {
    saveToDatabase(result.data);
    sendWelcomeEmail(result.data.email);
  } else {
    console.error('Validation errors:', result.error.errors);
  }
}

More Zod Examples

import { z } from 'zod';

// Non-empty array
const NonEmptyArraySchema = z.array(z.string()).nonempty();
type NonEmptyStringArray = z.infer<typeof NonEmptyArraySchema>;

// Positive number
const PositiveNumberSchema = z.number().positive();
type PositiveNumber = z.infer<typeof PositiveNumberSchema>;

// Custom validation
const ProductPriceSchema = z.number()
  .positive('Price must be positive')
  .max(1000000, 'Price too high')
  .refine(val => val % 0.01 === 0, 'Price must have at most 2 decimal places');

// Transform while parsing
const DateFromStringSchema = z.string().transform(str => new Date(str));

// API response parsing
const ApiUserSchema = z.object({
  id: z.number(),
  email: z.string().email(),
  name: z.string().min(1),
  age: z.number().int().min(18),
  created_at: z.string().transform(str => new Date(str)),
});

type ApiUser = z.infer<typeof ApiUserSchema>;

async function fetchUser(userId: number): Promise<ApiUser> {
  const response = await fetch(`/api/users/${userId}`);
  const data = await response.json();

  // Parse and validate in one step
  return ApiUserSchema.parse(data);
}

Practical Patterns

Pattern 1: Parse at System Boundaries

// ❌ Bad: Validating throughout the system
app.post('/users', (req, res) => {
  const userData = req.body;
  // userData flows deep into system unvalidated
  await userService.createUser(userData);
});

// ✅ Good: Parse at the boundary
app.post('/users', (req, res) => {
  try {
    const validUser = UserSchema.parse(req.body);  // Parse here!
    await userService.createUser(validUser);  // Now guaranteed valid
    res.json({ success: true });
  } catch (error) {
    res.status(400).json({ error: 'Invalid user data' });
  }
});

Pattern 2: Smart Constructors

class Email {
  private constructor(private readonly value: string) {}

  static create(input: string): Email {
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input)) {
      throw new Error('Invalid email format');
    }
    return new Email(input);
  }

  toString(): string {
    return this.value;
  }
}

class PositiveNumber {
  private constructor(private readonly value: number) {}

  static create(input: number): PositiveNumber {
    if (input <= 0) {
      throw new Error('Number must be positive');
    }
    return new PositiveNumber(input);
  }

  valueOf(): number {
    return this.value;
  }
}

// Usage
const email = Email.create('user@example.com');  // ✅
const email2 = Email.create('invalid');  // ❌ Throws

const price = PositiveNumber.create(99.99);  // ✅
const price2 = PositiveNumber.create(-10);  // ❌ Throws

Pattern 3: Builder Pattern with Validation

class UserBuilder {
  private email?: Email;
  private age?: Age;
  private username?: Username;

  setEmail(input: string): this {
    this.email = parseEmail(input);
    return this;
  }

  setAge(input: number): this {
    this.age = parseAge(input);
    return this;
  }

  setUsername(input: string): this {
    this.username = parseUsername(input);
    return this;
  }

  build(): ValidatedUser {
    if (!this.email || !this.age || !this.username) {
      throw new Error('Missing required fields');
    }

    return {
      email: this.email,
      age: this.age,
      username: this.username,
    };
  }
}

// Usage
const user = new UserBuilder()
  .setEmail('user@example.com')
  .setAge(25)
  .setUsername('john_doe')
  .build();

Pattern 4: Combining Multiple Parsers

type ParseResult<T> = Result<T, string>;

function combine<A, B>(
  a: ParseResult<A>,
  b: ParseResult<B>
): ParseResult<[A, B]> {
  if (!a.success) return a;
  if (!b.success) return b;
  return ok([a.value, b.value]);
}

function combine3<A, B, C>(
  a: ParseResult<A>,
  b: ParseResult<B>,
  c: ParseResult<C>
): ParseResult<[A, B, C]> {
  if (!a.success) return a;
  if (!b.success) return b;
  if (!c.success) return c;
  return ok([a.value, b.value, c.value]);
}

function parseUserInputCombined(input: UserInput): ParseResult<ValidatedUser> {
  const results = combine3(
    parseEmailSafe(input.email),
    parseAgeSafe(input.age),
    parseUsernameSafe(input.username)
  );

  if (!results.success) {
    return results;
  }

  const [email, age, username] = results.value;
  return ok({ email, age, username });
}

Common Mistakes to Avoid

Mistake 1: Not Parsing Early Enough

// ❌ Bad: Passing unvalidated data deep into system
async function processOrder(orderData: any) {
  await saveOrder(orderData);  // What if orderData is invalid?
  await chargeCustomer(orderData.amount);  // What if amount is negative?
}

// ✅ Good: Parse at boundary
async function processOrder(orderData: unknown) {
  const order = OrderSchema.parse(orderData);  // Parse immediately!
  await saveOrder(order);
  await chargeCustomer(order.amount);
}

Mistake 2: Parsing but Not Using the Parsed Type

// ❌ Bad: Validates but doesn't use result
function processEmail(email: string) {
  const result = parseEmailSafe(email);
  if (result.success) {
    sendEmail(email);  // ❌ Still using original string!
  }
}

// ✅ Good: Use the parsed value
function processEmail(email: string) {
  const result = parseEmailSafe(email);
  if (result.success) {
    sendEmail(result.value);  // ✅ Using validated Email type
  }
}

Mistake 3: Validating the Same Data Multiple Times

// ❌ Bad: Validating repeatedly
function updateUser(userId: number, email: string) {
  validateEmail(email);  // Validate once
  const user = await getUser(userId);
  validateEmail(email);  // Validate again - why?
  user.email = email;
  await saveUser(user);
}

// ✅ Good: Parse once, use everywhere
function updateUser(userId: number, emailInput: string) {
  const email = parseEmail(emailInput);  // Parse once
  const user = await getUser(userId);
  user.email = email;  // Use validated type
  await saveUser(user);
}

Key Principles Summary

  1. Parse at boundaries: Convert untrusted input to validated types immediately
  2. Use precise types: Make invalid states unrepresentable
  3. Parse once: Don't validate repeatedly throughout your code
  4. Types carry guarantees: If a function accepts Email, it's guaranteed valid
  5. Fail fast: Catch invalid data before it enters your system
  6. Transform, don't check: Return refined types, not booleans

The Mantra

Don't check if data is valid and pass it along.
Transform data into types that guarantee validity.


Further Reading

Popular TypeScript Parsing/Validation Libraries

  • Zod - Most popular, excellent DX
  • io-ts - More functional programming oriented
  • Yup - Good for forms
  • Joi - Originally for Node.js
  • Valibot - Newer, smaller bundle size
  • ArkType - Uses TypeScript syntax

Most of these follow the "parse, don't validate" philosophy!