Data models in TypeScript, from primitive obsession to clean entities.

Alberto Basalo
7 min readDec 22, 2022

Data is the raw material of our programs. We must pay attention to its definition, cohesion, and correctness. Often we find information that is scattered, redundant, or missing. This leads us to dirty and error-prone developments.

Bad programmers care about the code. The good ones care about data structures and their relationships.

Linus Torvalds

Some solutions are unnecessarily complex. Other times they are applied out of place. I present to you a path of evolution so that, little by little, you can shape your business model. And most importantly, write maintainable programs in clean Typescript.

Data models evolution

🎒 Prerequisites

To complete this tutorial, you will need:

  • Be aware of clean coding naming rules.

Let there be data.

0️⃣ Primitive obsession

This scenario takes its name from the fact that we are using primitive types to represent our data. Very common at the beginning of a developer’s career. But, as we will see, it is not the best solution.

Common problems are:

  • Redundant names
  • Lack of cohesion
  • lack of data validation (or pulled apart)
// ❌ bad smell naming 🤢
const clientName = "Mark Gates";
const clientCountry = "USA";
const clientCity = "Los Angeles"; // i am tired of writing client...
const isDeferredPayment = true; // is this still related to the client?
const amount = 999; // could it be negative?
const deferredMonths= 0; // is it related to isDeferredPayment?
const isRecurredPayment = false; // could be true when isDeferredPayment is also true?
const cardNumber = "1234123412341234"; // it is a string, so could it accept letters?
const cardValidUntil = "12/29"; // could accept 2026-6?
const cardVerificationCode = 123; // is a number or a string; is -1F valid?

1️⃣ Data Transfer Objects

The first and most common solution to repetitive naming and lack of cohesion is to use Data Transfer Objects. They are a way to group related fields.

In TypeScript, you can use a type or an interface to declare the shape of your DTOs. I prefer the latter because of my Java and C# background.

// ✅ interfaces, types, or classes bring cohesion

interface ClientDTO {
name: string;
country: string;
city: string;
}

interface PaymentDTO {
amount: number;
isDeferred: boolean;
deferredMonths: number;
isRecurred: boolean;
}

interface CardDTO {
number: string;
validUntil: string;
verificationCode: number;
}

2️⃣ Value Objects

Here we have an evolution step. We are going to use Value Objects to validate and represent our data. The price to pay is that we will need to define classes and instantiate objects on them.

The main idea is to set validation rules close to defined data. This way, we can avoid errors and improve the readability of our code. These rules are called invariants, conditions that must be met for the object to be valid.

We can write them in the constructor to ensure a clean start. Also, we can use readonly properties to prevent data from being modified or implement a rule in a set method for mutable data.

// ✅ Enforce invariant rules that ensure data quality

class ClientVO {
// 😏 immutable data
constructor(
public readonly name: string,
public readonly country: string,
public readonly city: string) {
if (name.length < 3) {
throw new Error("Name must be at least 3 characters");
}
}
}

class PaymentVO {
// 😏 mutable, but with validation
private _amount: number = 0;
public get amount(): number {
return this._amount;
}
public set amount(value: number) {
if (value < 0) {
throw new Error("Amount must be greater than 0");
}
this._amount = value;
}
constructor(
amount: number,
public readonly isDeferred: boolean,
public readonly deferredMonths: number,
public readonly isRecurred: boolean
) {
this.amount = amount;
if (isDeferred && isRecurred) {
throw new Error("Payment can't be deferred and recurred");
}
if (isDeferred && deferredMonths <= 0) {
throw new Error("Months deferred must be greater than 0");
}
}
}

// ✅ add functionality to data (construction, representation...)

class CardVO {
public readonly number: string;
public readonly validUntil: string;
public readonly verificationCode: number;

constructor(
number: string,
validUntil: string,
verificationCode: number) {
// 😏 complex validations on their own methods
this.number = this.getNumber(number);
this.validUntil = this.getValidUntil(validUntil);
this.verificationCode = this.getVerificationCode(verificationCode);
}

private getNumber(number: string) {
number = number.replace(/\s/g, "");
if (number.length !== 16 && number.match(/[^0-9]/)) {
throw new Error("Card number must be 16 digits");
}
return number;
}
private getValidUntil(validUntil: string): string {
validUntil = validUntil.replace(/\s/g, "");
if (validUntil.length !== 5 && validUntil.match(/[^0-9/]/)) {
throw new Error("Valid until must be 5 digits only and a slash");
}
if (parseInt(validUntil.substring(0, 2)) > 12) {
throw new Error("Month must be between 1 and 12");
}
return validUntil;
}
private getVerificationCode(verificationCode: number): number {
if (verificationCode < 100 || verificationCode > 999) {
throw new Error("Verification code must be between 100 and 999");
}
return verificationCode;
}
// 😏 change representation without changing the value
getExpirationDate() {
const monthOrdinal = parseInt(this.validUntil.substring(0, 2)) - 1;
const year = parseInt(this.validUntil.substring(3, 5));
return new Date(year, monthOrdinal, 1);
}
getMaskedNumber() {
const last = this.number.substring(12);
const maskedNumber = `**** **** **** ${last}`;
return maskedNumber;
}
}

3️⃣ Entities

Until now, we have stored, validated, and represented our data. But what if we need to add some behavior to them? We can use Entities to do that.

Here, the focus is on business logic. We can add methods to our classes to perform operations on the data, which is now encapsulated in a property.

Entities have data and give a home for the business rules related to it.

// ✅ Entities do more than just manipulate data

export class Client {
constructor(public readonly clientData: ClientVO) {}
// no behavior yet, only a wrapper for the data
}

export class Card {
constructor(public readonly cardData: CardVO) {}

isExpired() {
// 😏 pure logic or dependent on context? encapsulated with it
return this.cardData.getExpirationDate() < new Date();
}

checkCardLimit(amount: number) {
// 😏 impure or complex logic? entity is your home
console.log(`get card limit online...`);
const limit = 1000;
if (amount > limit) {
throw new Error(`Card ${this.cardData.number} limit exceeded`);
}
return true;
}
}

export class Payment {
constructor(public readonly data: PaymentVO) {}

payWithCard(card: Card) {
// 😏 an entity can use other entities
const cardMasked = card.cardData.getMaskedNumber();
if (card.isExpired()) {
throw new Error(`Card ${cardMasked} is expired`);
}
card.checkCardLimit(this.data.amount);
console.log(`Charged ${this.data.amount} on card ${cardMasked}`);
}
}

4️⃣ Aggregates

Sometimes, we need to group entities to perform operations on them. In other cases, we want to ensure relations and cardinality between entities. Either way, we can use Aggregates to do that.

// ✅ Entities can be aggregated in complex hierarchies


// 😏 a client with his cards
export class ClientAggregate {
public readonly cards: Card[] = [];

constructor(
public readonly client: Client,
private preferredCard: Card) {}

addCard(card: Card, isPreferred: boolean) {
// 😏 ensures that the client always has a card marked as preferred
this.cards.push(card);
if (isPreferred) {
this.preferredCard = card;
}
}
getPreferredCard() {
return this.preferredCard;
}
}

// 😏 a client(with his cards) with his payments
export class ClientPaymentsAggregate {
// 😏 stores related data
private payments: PaymentVO[] = [];

// 😏 we can aggregate an entity or another aggregate
constructor(public readonly client: ClientAggregate) {}

performPayment(payment: Payment) {
const card = this.client.getPreferredCard();
payment.payWithCard(card);
this.payments.push(payment.data);
}
getPayments() {
return [...this.payments];
}
}

5️⃣ Domain

The Domain is the bread and butter of our application. The problem to solve. The reason why we are paid.

Every application has a Domain. Well-architected applications have their domain explicitly declared and apart. Encapsulating the things independent of the framework, database, or any other external detail.

Technology changes faster than business. Keep them apart to allow evolution at a different pace.

Going deeper into this topic is beyond the intention of this post. You can learn more about Domain and Domain Driven Design here.

🌅 Conclusion

We have seen how to use Value Objects, Entities, and Aggregates to encapsulate and add behavior to our data. We also know what Domain is. Now is the time to evolve and place our code above primitive solutions.

You can read my post about data modeling with anemic and rich models to learn more about data modeling with anemic and rich models.

This post can help you write better and clean TypeScript code and even help your bots help you. 🤖

🙏🏼If you liked this post, consider clapping or sharing it. Check my profile to get in contact or find more info about clean code, testing, and best practices with TypeScript.

learn, code, enjoy, repeat.

Alberto Basalo

Alberto Basalo, elevating code quality.

--

--

Alberto Basalo

Advisor and instructor for developers. Keeping on top of modern technologies while applying proven patterns learned over the last 25 years. Angular, Node, Tests