TypeScript Value Objects help you eliminate primitive obsession by wrapping raw data in domain-specific types. Learn to prevent bugs with better type safety.
I spent three days last month tracking down a bug where a user's ID was accidentally swapped with a product ID in a function call. Both were just string types, and the compiler didn't blink, letting the code sail through to production. That was the moment I decided to stop letting primitives run wild in my domain logic.
Primitive obsession—using string, number, or boolean for everything—is a silent killer. It leads to code that looks fine on the surface but is brittle, hard to test, and prone to "type-coercion" bugs. By shifting to TypeScript Value Objects, you can capture domain rules directly in your types.
When you define a user model as type User = { id: string, email: string }, you’ve told TypeScript that any string is a valid ID. But a user ID might be a UUID, while an email must contain an @ symbol. If you pass an email where an ID is expected, TypeScript remains silent.
We first tried solving this by creating simple classes with validation logic, but we kept forgetting to call the validate() method. It felt like we were fighting the language rather than using it to our advantage. If you want to enforce stricter domain integrity, you should also look into TypeScript Branded Types: Enforcing Domain Integrity at Compile-Time for lighter-weight constraints.
A Value Object is an object that represents a descriptive aspect of the domain with no conceptual identity. Two Email objects with the same value are considered equal.
Here is how I structure a robust Email value object:
TYPESCRIPTclass Email { private readonly value: string; private constructor(value: string) { this.value = value; } public static create(value: string): Email { if (!value.includes(CE9178">'@')) { throw new Error(CE9178">'Invalid email format'); } return new Email(value); } public getValue(): string { return this.value; } }
By making the constructor private, I force developers to use the create factory method. This guarantees that an Email instance is always valid. You can no longer accidentally instantiate an Email with garbage data.
Once you move away from primitives, your function signatures become self-documenting. Instead of sendWelcome(email: string), you write sendWelcome(email: Email).
This shift isn't just about safety; it's about clarity. When you see Email in a function signature, you know exactly what the contract is. If you're building complex APIs, you might find that combining these with TypeScript Template Literal Types for Robust API Design allows you to enforce even stricter formats, like specific URL structures or slug patterns.
Adopting Domain Driven Design patterns in TypeScript has a cost. You’ll write more boilerplate. You have to map between your database layer (which usually returns raw JSON) and your domain objects.
I’ve found that using a mapping layer is worth the roughly 10% increase in initial development time. It pays for itself the first time you refactor a core entity and the compiler highlights exactly where your data transformations are now invalid. For more complex data shapes, TypeScript Conditional Types for Smarter, Self-Documenting Data Transformers can help automate these mappings without losing type safety.
Does this add runtime overhead? Yes, slightly. You’re instantiating objects rather than passing strings. In 99% of web applications, this cost is negligible compared to the network latency of your API calls.
How do I handle serialization?
Since these are just objects, you should add a toJSON() method to your Value Objects. This ensures that when you pass them to JSON.stringify(), they serialize correctly back into the primitive values your API expects.
Can I use these for everything? Probably not. Don't wrap every single property in a Value Object. Use them for "domain-heavy" fields—IDs, emails, currencies, or coordinates—where validation logic is complex or critical.
I'm still refining how I handle equality checks for nested value objects. Currently, I'm manually checking equality in my equals() methods, but I've been experimenting with deep-comparison utilities. There's always a balance to strike between "strict enough to be safe" and "so strict that the codebase becomes unreadable."
Start by refactoring one critical entity in your system—maybe your Money or UserID type—and see if the compiler catches those "impossible" bugs for you. You'll likely find that the extra structure is a small price to pay for the peace of mind.
TypeScript Conditional Types turn messy data transformations into type-safe operations. Stop using `any` and learn to build self-documenting code that scales.
Read moreTypeScript recursive conditional types help you prevent impossible states in complex configuration objects. Learn to enforce deep type safety at compile-time.