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
Emaildon'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
- Parse at boundaries: Convert untrusted input to validated types immediately
- Use precise types: Make invalid states unrepresentable
- Parse once: Don't validate repeatedly throughout your code
- Types carry guarantees: If a function accepts
Email, it's guaranteed valid - Fail fast: Catch invalid data before it enters your system
- 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
- Original "Parse, Don't Validate" by Alexis King
- Zod Documentation
- io-ts - Another TypeScript validation library
- TypeScript Handbook - Narrowing
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!