Value Objects & Entities in TypeScript: Building Blocks That Can't Break
TL;DR — What is a Value Object?
A Value Object is an immutable object defined entirely by its attributes. Two
Email("[email protected]")are equal because their values match, not because they share a reference. Value Objects have four properties:
- Immutability — created once, never modified
- Equality by value —
equals()compares internal state- Self-validating — cannot exist in an invalid state
- No side effects — pure construction
The Validation Everywhere Problem
Last time, I showed you how TDD gives you permission to refactor. But there’s a problem I glossed over: what happens when the same validation rule appears in five different places?
I ran into this on a real project. Email validation lived in four separate files:
// ❌ Scattered across the codebase
if (!req.body.email.includes("@")) { return res.status(400).json({ error: "Invalid email" });}
// src/services/userService.tsif (!email.includes("@")) { return err(new InvalidEmailError());}
// src/middleware/validateRegistration.tsif (!input.email.includes("@")) { return res.status(400).json({ field: "email", message: "Invalid" });}
// src/utils/validators.tsexport function isValidEmail(email: string): boolean { return email.includes("@");}Four files. Four implementations. Slightly different error messages. And when the product team asked us to also reject emails longer than 254 characters, I had to find and update all four places.
I missed one. A bug shipped to production.
That’s when I realized the real problem was design — testing only revealed it. I was encoding validation into the data, not applying it to the data.
In the last article, I introduced the Result<T, E> pattern and hinted at Value Objects. I’ll build them here, with TDD driving every decision.
The thesis: Value Objects encode rules into types. Once you have them, validation disappears from your services because the types themselves reject bad data.
What Is a Value Object?
A Value Object is an immutable object defined by its attributes, not its identity. Two Email("[email protected]") objects are equal because their values are equal, not because they share a reference.
Value Objects have four properties:
- Immutability: created once, never modified
- Equality by value:
equals()compares internal state - Self-validating: cannot exist in an invalid state
- No side effects: pure construction
This is what that looks like. Compare the primitive approach with the Value Object:
// ❌ Primitive: any string is "valid"const email: string = "not-an-email";const email2: string = "";
// ✅ Value Object: only valid emails can existclass InvalidEmailError { readonly code = "INVALID_EMAIL"; constructor(public readonly input: string) {}}
class Email { private constructor(private readonly value: string) {}
static create(value: string): Result<Email, InvalidEmailError> { const trimmed = value.trim().toLowerCase(); if (!trimmed.includes("@") || !trimmed.includes(".")) { return err(new InvalidEmailError(value)); } if (trimmed.length > 254) { return err(new InvalidEmailError(value)); } return ok(new Email(trimmed)); }
getValue(): string { return this.value; }
equals(other: Email): boolean { return this.value === other.value; }}The private constructor is the key. You can’t call new Email("garbage"). The only way to create an Email is through Email.create(), which validates the input and returns a Result. Invalid states are impossible to construct by accident — the factory refuses to produce them.
This is Domain-Driven Design 101 — Martin Fowler’s definition of Value Object is the clearest place to start. Most TypeScript developers I meet have only seen the pattern in Java/C#, where it tends to get dismissed as enterprise ceremony. That’s a mistake. It’s the simplest tool I know for eliminating an entire class of bugs.
(One assumption I’m making throughout: strict: true and strictNullChecks: true in your tsconfig.json. The discriminated-union narrowing in Result<T, E> only works safely under strict mode.)
The TDD Cycle for Value Objects
I didn’t design Email on a whiteboard. I let tests drive it. Here’s the full red-green-refactor cycle.
Iteration 1: Basic Creation (Red)
I started by writing the tests I wanted to pass:
describe("Email Value Object", () => { it("should create a valid email", () => {
expect(result.ok).toBe(true); if (result.ok) { } });
it("should reject email without @", () => { const result = Email.create("no-at-sign");
expect(result.ok).toBe(false); if (!result.ok) { expect(result.error.code).toBe("INVALID_EMAIL"); } });});Tests fail. Good. That’s the red phase.
Iteration 1: Basic Creation (Green)
The simplest implementation that passes:
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
function ok<T>(value: T): Result<T, never> { return { ok: true, value };}
function err<E>(error: E): Result<never, E> { return { ok: false, error };}
class InvalidEmailError { readonly code = "INVALID_EMAIL"; constructor(public readonly input: string) {}}
class Email { private constructor(private readonly value: string) {}
static create(value: string): Result<Email, InvalidEmailError> { if (!value.includes("@")) { return err(new InvalidEmailError(value)); } return ok(new Email(value)); }
getValue(): string { return this.value; }}Tests pass. Green.
Iteration 2: Edge Cases (Red)
Now I push the design by adding more tests:
it("should normalize email to lowercase", () => {
expect(result.ok).toBe(true); if (result.ok) { }});
it("should trim whitespace", () => {
expect(result.ok).toBe(true); if (result.ok) { }});
it("should reject empty string", () => { const result = Email.create("");
expect(result.ok).toBe(false);});
it("should reject email without a domain suffix", () => { const result = Email.create("user@localhost");
expect(result.ok).toBe(false);});
it("should reject email over 254 characters", () => { const long = "a".repeat(250) + "@b.co"; const result = Email.create(long);
expect(result.ok).toBe(false);});Iteration 2: Edge Cases (Green)
I update Email.create() to pass all tests:
static create(value: string): Result<Email, InvalidEmailError> { const trimmed = value.trim().toLowerCase(); if (!trimmed.includes("@") || !trimmed.includes(".")) { return err(new InvalidEmailError(value)); } if (trimmed.length > 254) { return err(new InvalidEmailError(value)); } return ok(new Email(trimmed));}Green. All tests pass.
Iteration 3: Equality (Red)
One more behavior I need: can I compare two emails?
describe("Email Equality", () => { it("should consider same emails equal", () => {
if (a.ok && b.ok) { expect(a.value.equals(b.value)).toBe(true); } });
it("should consider different emails unequal", () => {
if (a.ok && b.ok) { expect(a.value.equals(b.value)).toBe(false); } });});Iteration 3: Equality (Green)
equals(other: Email): boolean { return this.value === other.value;}The insight: the equals() method only exists because a test required it. I didn’t plan it upfront. TDD exerted design pressure: the tests told me what the object needed to do, and I responded.
This is what I mean when I say TDD is design work. The tests come along for the ride.
Building More Value Objects
Once you’ve built one Value Object, the pattern repeats. Same shape. Same TDD cycle. Two more examples follow.
Age Value Object
class InvalidAgeError { readonly code = "INVALID_AGE"; constructor(public readonly reason: string) {}}
class Age { private constructor(private readonly value: number) {}
static create(value: number): Result<Age, InvalidAgeError> { if (!Number.isInteger(value)) { return err(new InvalidAgeError("Age must be a whole number")); } if (value < 0) { return err(new InvalidAgeError("Age cannot be negative")); } if (value < 18) { return err(new InvalidAgeError("Must be at least 18")); } if (value > 150) { return err(new InvalidAgeError("Age seems unrealistic")); } return ok(new Age(value)); }
getValue(): number { return this.value; }
equals(other: Age): boolean { return this.value === other.value; }}And the tests that drove it:
describe("Age Value Object", () => { it("should create a valid age", () => { const result = Age.create(25); expect(result.ok).toBe(true); });
it("should reject negative ages", () => { const result = Age.create(-1); expect(result.ok).toBe(false); });
it("should reject ages under 18", () => { const result = Age.create(17); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error.reason).toContain("18"); } });
it("should reject non-integer ages", () => { const result = Age.create(25.5); expect(result.ok).toBe(false); });
it("should reject unrealistic ages", () => { const result = Age.create(200); expect(result.ok).toBe(false); });});Username Value Object
class InvalidUsernameError { readonly code = "INVALID_USERNAME"; constructor(public readonly reason: string) {}}
class Username { private constructor(private readonly value: string) {}
static create(value: string): Result<Username, InvalidUsernameError> { const trimmed = value.trim(); if (trimmed.length < 3) { return err( new InvalidUsernameError("Username must be at least 3 characters") ); } if (trimmed.length > 30) { return err( new InvalidUsernameError("Username must be at most 30 characters") ); } if (!/^[a-zA-Z0-9_]+$/.test(trimmed)) { return err( new InvalidUsernameError( "Username can only contain letters, numbers, and underscores" ) ); } return ok(new Username(trimmed)); }
getValue(): string { return this.value; }
equals(other: Username): boolean { return this.value === other.value; }}Notice the pattern? Every Value Object follows the same shape:
- Private constructor: you can’t create one directly
- Static factory returning
Result<T, E>: validation happens here - Getter for the internal value
equals()for comparison- All validation in the factory
I didn’t plan this pattern. It emerged from writing the same test structure over and over. TDD revealed it.
What Is an Entity?
Value Objects are defined by their attributes. But some things in your domain have identity: they matter because of who they are, not what they contain.
A User with email [email protected] and age 25 is different from another User with the same data but a different ID. The ID is what matters. That’s an Entity.
Entities have four properties:
- Identity: a unique ID that persists across state changes
- Mutable: attributes can change over time (but the ID never does)
- Equality by identity: two users with the same ID are the same, regardless of their current data
- Contains Value Objects: attributes are Value Objects, not primitives
Here’s a UserId Value Object (yes, IDs are Value Objects) and a User Entity:
class UserId { private constructor(private readonly value: string) {}
static generate(): UserId { // Node 19+ / modern browsers expose crypto.randomUUID globally. // On older Node versions: `import { randomUUID } from "node:crypto"`. return new UserId(crypto.randomUUID()); }
static fromString(value: string): UserId { return new UserId(value); }
getValue(): string { return this.value; }
equals(other: UserId): boolean { return this.value === other.value; }}
type ValidationError = InvalidEmailError | InvalidAgeError | InvalidUsernameError;
class User { private constructor( private readonly id: UserId, private email: Email, private age: Age, private username: Username ) {}
static create( emailStr: string, ageNum: number, usernameStr: string ): Result<User, ValidationError> { const emailResult = Email.create(emailStr); if (!emailResult.ok) return err(emailResult.error);
const ageResult = Age.create(ageNum); if (!ageResult.ok) return err(ageResult.error);
const usernameResult = Username.create(usernameStr); if (!usernameResult.ok) return err(usernameResult.error);
return ok( new User( UserId.generate(), emailResult.value, ageResult.value, usernameResult.value ) ); }
static of(id: UserId, email: Email, age: Age, username: Username): User { return new User(id, email, age, username); }
changeEmail(newEmailStr: string): Result<void, InvalidEmailError> { const result = Email.create(newEmailStr); if (!result.ok) return err(result.error); this.email = result.value; return ok(undefined); }
getEmail(): Email { return this.email; }
getAge(): Age { return this.age; }
getUsername(): Username { return this.username; }
getId(): UserId { return this.id; }
equals(other: User): boolean { return this.id.equals(other.id); }}Look at User.create(). It doesn’t validate anything itself. It delegates to Email.create(), Age.create(), and Username.create(). The Entity is thin because the Value Objects do the heavy lifting.
User.of(id, email, age, username) is the second factory: same construction, but you pass in a known UserId instead of generating one. We’ll need that when we rehydrate a User from a database in the next post.
And changeEmail(): same pattern. It doesn’t check @ signs. It trusts Email.create() to enforce the rules. One line of delegation instead of five lines of validation.
Here are the tests that drove this Entity:
describe("User Entity", () => { it("should create a user with valid email, age, and username", () => {
expect(result.ok).toBe(true); if (result.ok) { expect(result.value.getAge().getValue()).toBe(25); expect(result.value.getUsername().getValue()).toBe("alice_99"); } });
it("should reject user with invalid email", () => { const result = User.create("not-an-email", 25, "alice_99");
expect(result.ok).toBe(false); });
it("should reject user under 18", () => {
expect(result.ok).toBe(false); });
it("should reject user with invalid username", () => {
expect(result.ok).toBe(false); });
it("should change email successfully", () => { if (!user.ok) return;
expect(result.ok).toBe(true); });
it("should reject invalid email on change", () => { if (!user.ok) return;
const result = user.value.changeEmail("bad");
expect(result.ok).toBe(false); });
it("should treat users with the same ID as equal, even if their data differs", () => { const id = UserId.fromString("user-123"); const age = Age.create(25); const username = Username.create("alice_99");
if (!email.ok || !age.ok || !username.ok) return;
const userA = User.of(id, email.value, age.value, username.value); const userB = User.of(id, email.value, age.value, username.value);
expect(userA.equals(userB)).toBe(true); });
it("should treat users with different IDs as unequal, even with the same data", () => { const age = Age.create(25); const username = Username.create("alice_99");
if (!email.ok || !age.ok || !username.ok) return;
const userA = User.of( UserId.fromString("user-A"), email.value, age.value, username.value ); const userB = User.of( UserId.fromString("user-B"), email.value, age.value, username.value );
expect(userA.equals(userB)).toBe(false); });});Two User objects built with the same UserId are equal — regardless of what their attributes currently say. Two User objects built with different UserIds are unequal — even if every attribute matches. That’s the Entity identity rule, enforced by the tests, expressed through User.of(id, ...).
Value Object vs Entity: How to Decide

When I first learned this distinction, I kept asking: “But how do I know which one to use?”
The decision framework I reach for:
Does it have a unique identity that persists across changes? ├── Yes → Entity └── No → Value Object
Can it change over time? ├── Yes, and it has identity → Entity ├── Yes, but no identity → Create a new Value Object (immutability) └── No → Value Object
Is equality based on attributes or identity? ├── Attributes → Value Object └── Identity → EntitySome concrete examples from domains I’ve worked in:
| Concept | Type | Why |
|---|---|---|
| Value Object | No identity, immutable, compared by value | |
| User | Entity | Has ID, email can change, compared by ID |
| Money | Value Object | No identity, 5 |
| Order | Entity | Has order ID, items change over time |
| Address | Value Object | Compared by street + city + zip |
| Customer | Entity | Has customer ID, address can change |
| DateRange | Value Object | Compared by start + end |
| Subscription | Entity | Has subscription ID, status changes over time |
When I’m unsure, I ask myself one question: “If two of these had the same data, would they be the same thing?”
- Two emails
[email protected]? Same thing. → Value Object. - Two users named Alice? Could be different people. → Entity.
Getting this wrong has consequences: identity logic scatters when you treat an Entity as a Value Object, and unnecessary overhead piles up when you treat a Value Object as an Entity.
Real-World: Rebuilding the Registration System

Time to put it all together. That scattered validation from the beginning — here’s the before and after.
Before: Validation Everywhere
// ❌ 80+ lines of a registration service
class RegistrationService { register(email: string, age: number, username: string): Result<User, Error> { // Validate email if (!email || !email.includes("@") || !email.includes(".")) { return err(new Error("Invalid email")); } const normalizedEmail = email.trim().toLowerCase(); if (normalizedEmail.length > 254) { return err(new Error("Email too long")); }
// Validate age if (typeof age !== "number" || !Number.isInteger(age)) { return err(new Error("Age must be a number")); } if (age < 18) { return err(new Error("Must be at least 18")); }
// Validate username if (!username || username.trim().length < 3) { return err(new Error("Username too short")); } if (username.trim().length > 30) { return err(new Error("Username too long")); } if (!/^[a-zA-Z0-9_]+$/.test(username.trim())) { return err(new Error("Invalid username characters")); }
// Finally, create the user const user = new User(normalizedEmail, age, username.trim()); return ok(user); }}One method. 30+ lines of validation. And if I need to validate an email anywhere else (password reset, email change, account linking), I have to duplicate or extract this logic.
After: Value Objects Do the Work
// ✅ ~15 lines, all validation delegated
class RegistrationService { register( emailStr: string, ageNum: number, usernameStr: string ): Result<User, ValidationError> { const emailResult = Email.create(emailStr); if (!emailResult.ok) return err(emailResult.error);
const ageResult = Age.create(ageNum); if (!ageResult.ok) return err(ageResult.error);
const usernameResult = Username.create(usernameStr); if (!usernameResult.ok) return err(usernameResult.error);
return ok( User.of( UserId.generate(), emailResult.value, ageResult.value, usernameResult.value ) ); }}The service doesn’t know what makes an email valid. It doesn’t care. It hands the raw strings to Email.create(), Age.create(), and Username.create(), then composes them into a User via User.of(...). Every layer trusts the layer below it: the service trusts the Value Objects, the Entity trusts the Value Objects, the Value Objects trust themselves. If the rules change (say, we add a check for disposable email domains), we update Email.create() in one place, and every layer above it benefits without changes.
Tests for the registration flow:
describe("Registration Service", () => { it("should register a valid user", () => { const service = new RegistrationService();
expect(result.ok).toBe(true); });
it("should reject invalid email during registration", () => { const service = new RegistrationService(); const result = service.register("not-an-email", 25, "alice_99");
expect(result.ok).toBe(false); if (!result.ok) { expect(result.error.code).toBe("INVALID_EMAIL"); } });
it("should reject underage user during registration", () => { const service = new RegistrationService();
expect(result.ok).toBe(false); if (!result.ok) { expect(result.error.code).toBe("INVALID_AGE"); } });
it("should reject invalid username during registration", () => { const service = new RegistrationService();
expect(result.ok).toBe(false); if (!result.ok) { expect(result.error.code).toBe("INVALID_USERNAME"); } });});These tests survive refactoring. They test the behavior, what comes in and what comes out, not the internal structure. I can rewrite the entire RegistrationService tomorrow and these tests won’t blink.
That’s the permission slip from the first article, made concrete.
FAQ
Should every domain concept become a Value Object?
No. Start with primitives. When you see the same validation appearing in two or more places, extract it into a Value Object. Premature abstraction is still premature, even when it’s domain-driven. I usually wait for the second duplication before extracting.
What about performance? Doesn’t wrapping primitives add overhead?
In the domains I work in (web backends, APIs), the overhead is negligible. You’re creating small objects instead of passing strings. The GC cost is trivial. If you’re building a game loop or a real-time system with millions of allocations per frame, this pattern might not fit. But for business logic? The safety is worth the microsecond.
How do I persist Value Objects to a database?
You persist their internal values. An Email becomes a VARCHAR column. An Age becomes an INTEGER. The ORM or query layer unwraps the Value Object with getValue(). The validation still happens at construction time. Your database just stores the raw value. I’ll show this pattern in detail when we get to Repositories.
Summary
Value Objects encode rules into types. Entities compose Value Objects and manage identity.
Together, they give you code where invalid states are impossible to construct by accident — not just unlikely.
The TDD insight here is deeper than it looks. I didn’t sit down and decide “I need an Email Value Object.” The tests told me. Every time I wrote a test that said “should reject invalid email,” the design pressure pushed me toward a self-validating type. The Value Object emerged from the tests.
That’s what TDD is for. Design first. The tests come as a byproduct.
Three things to remember:
- Value Objects are immutable, self-validating, compared by value. They make invalid states impossible to construct by accident.
- Entities have identity, can change over time, compared by ID. They compose Value Objects.
- TDD reveals the pattern. You don’t plan Value Objects. You let the tests push you toward them.
What’s Next?
Value Objects protect single attributes. Entities protect a single object’s identity. But what happens when business rules span multiple objects? “A user can’t place an order over $1000” involves User, Order, OrderLine, and Money. That’s not a single Value Object’s job.
Next time: Aggregates & Repositories — Refactoring Without Fear. We’ll build clusters of objects that enforce business rules together, and abstract away persistence so your domain tests don’t need a database.
Value Objects protect data. Aggregates protect invariants. Repositories protect persistence. That’s the next layer.
Continue the TDD & Software Design Series
- TDD Isn’t Just for Catching Bugs — It’s Your Permission Slip
- Value Objects & Entities — Building Blocks That Can’t Break (you are here)
- Aggregates & Repositories — Refactoring Without Fear
- Repository Updates in TypeScript: Locks and Optimistic Concurrency
- DDD Aggregate Boundaries and Persistence in TypeScript
- I Stopped Mocking Everything in TDD — Here’s What I Use Instead
- The Result Pattern: Why I Stopped Throwing Exceptions
- TDD in the Age of AI: Who Tests the Tests?
- TDD for Legacy Code: Start With Seams, Not Ideology
Questions? Hit reply. I read everything.
— Corentin
P.S. If you haven’t read the first article yet, start there. This one builds on the Result<T, E> pattern and the “test behavior, not implementation” principle. The whole series makes more sense in order.
Related Posts
Repository Updates in TypeScript: Locks and Optimistic Concurrency
Learn when transactional update closures, optimistic versioning, and rollback-safe repository fakes fit in TypeScript.
TypeScript DDD Aggregates & Repositories
Build DDD aggregates and repositories in TypeScript with TDD. Enforce invariants and test application flows with an in-memory repository.
TDD Isn't About Bugs — It's Your Permission to Refactor
Learn why test-driven development is really about permission to refactor, not catching bugs. With TypeScript examples, Result<T, E> patterns, and behavior-based testing from 3 years in production.