Value Objects in TypeScript: Goodbye to Primitives

10-02-2026

By Aitor Reviriego Amor

But beware, it's not just about validating data. It's about making your code stop talking about “text strings” and start speaking the language of your business.

The Problem: the chaos of primitives 😱

Imagine this very common situation:

function getArticle(slug: string): Article { return articles.find(article => article.slug === slug) } function getProfile(slug: string): Profile { return profiles.find(profile => profile.slug === slug) }

At first glance, everything seems correct. But… what happens if you do this?

const articleSlug = "my-awesome-article" const profile = getProfile(articleSlug) // Oops! 🐛

TypeScript doesn't complain because both are strings. But you're mixing apples and oranges: an article slug is NOT the same as a profile slug.

Another classic example:

interface Article { title: string slug: string date: string imageUrl: string }

They are all strings, but does slug represent a valid concept? (no spaces, lowercase), is date a real calendar date?, does imageUrl point to a path allowed by the system?

The answer is: we don't know. By using primitives, we allow representing states that shouldn't exist in our business (like an email without an at symbol or a date “2024-99-99”).

The Solution: modeling reality, not just validating

This is where our mindset changes. A Value Object is not just a “validator on steroids”. It is the digital representation of a concept in your domain.

What we do with Value Objects is restrict the infinite universe of primitive values (any string) to a limited set of values that make sense for your business. An Email is not just any string, it is a concept with its own rules. If the format is wrong, the object is not even created. Boom 💥.

It is characterized by the following:

Its key characteristics:

Immutability: once created, it does not change. If you need to correct an email, you create a new Email object, you don't modify the existing one. Equality by value: two Email objects with “user@example.com” are identical, regardless of being different instances in memory. Their identity is their value. Self-contained: they carry their business rules with them.

What is NOT a Value Object? (Entities vs VOs) An invoice, for example. An invoice has a lifecycle (draft → issued → paid) and changes over time. Two invoices with the same amounts but different serial numbers are different invoices. That is an Entity.

Note: Although the Invoice is an entity, its ID (InvoiceId) is a perfect candidate to be a Value Object. The ID validates that the format is correct (e.g., UUID) and restricts the type, but the Entity is the one that manages the lifecycle.

Let's see how to transform our code to model reality.

ArticleSlug 🎯

Instead of a simple string, we define what exactly an “Article Slug” means in our business.

export class ArticleSlug { private static readonly 'valid-slug-format' = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ // ... (other cleaning regex) private constructor(private readonly value: string) {} static fromString(value: string): ArticleSlug { const trimmedValue = value.trim() if (trimmedValue.length === 0) throw new Error("Article slug cannot be empty") if (!this['valid-slug-format'].test(trimmedValue)) { throw new Error(`Invalid article slug format: "${value}"`) } return new ArticleSlug(trimmedValue) } static fromUntrustedString(value: string): ArticleSlug { // Cleaning and normalization logic... return ArticleSlug.fromString(cleanedValue) } static fromTitle(title: string): ArticleSlug { // Generation logic from title... return ArticleSlug.fromString(normalized) } toString(): string { return this.value } equals(other: ArticleSlug): boolean { return this.value === other.value } }

The constructor is private to enforce the use of factory methods. These methods not only validate but also express intention. fromTitle(title) clearly tells us how a slug is born. Now the code documents the business rules: “A slug can only be born from a valid string or transformed from a title”.

ArticleDate 📅

Here we not only validate format, we apply business rules. In this hypothetical domain, articles cannot come from the future.

export class ArticleDate { private constructor(private readonly value: Date) {} static fromString(dateString: string): ArticleDate { const date = new Date(dateString) // Structural validation if (isNaN(date.getTime())) throw new Error(`Invalid date`) // Business Rule if (date > new Date()) { throw new Error(`Article date cannot be in the future`) } return new ArticleDate(date) } static now(): ArticleDate { return new ArticleDate(new Date()) } isAfter(other: ArticleDate): boolean { return this.value > other.value } }

Notice the isAfter or isBefore methods. We are encapsulating behavior. Instead of scattering date comparison logic throughout the app, the ArticleDate concept itself knows how to compare with others.

ImagePath 🖼️

ImagePath models an architectural decision: all images must be WebP and organized.

export class ImagePath { private constructor(private readonly value: string) {} static fromString(path: string): ImagePath { // ...validations starting with /images/ // ...validations for .webp extension return new ImagePath(trimmed) } }

If tomorrow the business decides that we now support AVIF, the change happens here. The rest of the application, which only knows that an ImagePath exists, continues to function without being aware of the technical details.

Bonus Track: The “Pure TypeScript” Alternative 🏷️

In this article, we have used class because it is very didactic and allows grouping data and methods (like equals or fromTitle). However, in the TypeScript ecosystem, there is a more functional trend that prefers using Algebraic Types or Branded Types.

This allows modeling concepts without the overhead of creating class instances, using the type system to create constraints:

// Define the "branded" type type Email = string & { readonly __brand: unique symbol } // Validator function (Type Guard or Factory) function createEmail(value: string): Email { if (!value.includes('@')) throw new Error("Invalid email") return value as Email } // Usage const email = createEmail("hello@test.com") // ✅ Is type Email const text = "hello@test.com" // ❌ Is type string // const invalid: Email = text // Compilation error

This technique is very powerful if you only seek strict type safety without the need for attached methods to the object.

Using Value Objects in Practice 💼

Now our interfaces are expressive and safe:

interface Article { title: string slug: ArticleSlug // ✅ Domain concept date: ArticleDate // ✅ Applied time rules image: ImagePath // ✅ Architectural restriction content: string }

In repositories

Repositories act as the boundary. When raw data (from a DB or Markdown) enters, it is immediately converted into Value Objects. If there is corrupted data, we fail fast and before polluting the domain logic.

// ... inside MarkdownArticlesRepository private toDomain(frontmatter: any, slug: ArticleSlug): Article { return { title: frontmatter.title, slug: slug, date: ArticleDate.fromString(frontmatter.date), // Here we apply the rules image: ImagePath.fromString(frontmatter.image), // ... } }

Benefits: beyond validation 🌟

Rich semantics: your code talks about ArticleSlugs and Emails, not strings. It reads like the business. Real Type Safety: TypeScript prevents you from passing a Profile Slug to a function that expects an Article Slug. Cohesion: the logic of “what is a valid date” lives in ArticleDate, not scattered in 20 different if statements throughout the code. Confidence: if you have an instance of a VO, you know it is valid. You don't have to check it again.

Conclusion 🚀

Value Objects transform weak primitive types into strong domain concepts. Don't use them for everything (a simple text comment can still be a string), use them when the data has conceptual identity or its own rules in your business.

The next time you go to write string, ask yourself: Is this just text, or is it an important concept in my domain?

Your future self (and your compiler) will thank you.