Domain Type System for TypeScript
Like everything that happens nowadays in tech, this article starts from AI :)
Lately, I've been generating a lot of code using LLMs and agents. The raw output gets the job done, but during the maintenance phase, I've found that the resulting code and overall codebase understandability are... let's say, not ideal. This led me to search for ways to structure code that can act as a "cage" for AI - constraining it to reduce bugs and minimize the time I spend manually correcting its output.
LLMs can output tremendous amounts of code per iteration, but when we need quality and workability that meets our development goals, the only "tool" we can rely on is human review - which is inherently slow. The more noise there is in the AI's output, the more mental detours our brains must take, and the more issues slip through unnoticed.
I built custom linters and additional codebase checks, which act as automated feedback for AI agents during the generation loop. Which partially improved the situation, but still, they could not cover issues in logic, especially in projects that are logic-heavy.
Here's what I mean by logical errors: In one code review, I missed that AI had confused entity IDs deep within the business logic, leading to very annoying and hard-to-debug issues. In another case, AI treated admin users and public users as interchangeable, attempting to fetch public user profiles for admin users 🤦
These are exactly the kinds of problems that linters can't catch - they're logical, not syntactic. This got me thinking about applying Domain-Driven Design (DDD) principles to make the code more "sound" and restrictive, ideally improving the LLM's ability to self-correct.
Now, the example with regular users and admin users is relatively straightforward to fix in TypeScript. We can introduce value types by utilizing so-called brand types in TypeScript for user IDs.
type UserId = string & { readonly brand: unique symbol };
type AdminId = string & { readonly brand: unique symbol };
function getUserProfile(userId: UserId): UserProfile {
// fetch user profile by user id
}
function getAdminProfile(adminId: AdminId): AdminProfile {
// fetch admin profile by admin id
}As you can see, now it's impossible to accidentally mix admin and user IDs - the type system prevents it at compile time.
While this brand types approach works beautifully for simple cases, there are bigger, more complex logical structures that remain problematic. In typical architectures, once an LLM gains access to repositories and databases, it can construct operations that fetch anything from anywhere, completely bypassing business rules.
Traditional DDD with aggregates and synchronous business rules offers a solution, but it's poorly suited for distributed systems where you need high performance, clear transactional boundaries, and operational reliability. This tension led me to explore whether I could combine DDD modeling principles while preserving these critical quality attributes.
Problem Example
To illustrate the different approaches and their trade-offs, let me walk you through a concrete example that we'll use throughout this article.
Consider a conference registration system with the following requirements:
- Conferences have a limited number of seats
- We need to register attendees while avoiding overbooking
- When someone registers, we reserve their spot but only confirm it after payment
- If payment fails or times out, we release the reserved space
This scenario captures the complexity of real-world business logic: state management, concurrency concerns, and business rules that need to be enforced consistently.
Traditional DDD
Let's see a draft of how a simplified conference booking system could look like using the traditional DDD approach.
// Brand types to avoid confusing plain strings everywhere
type Brand<T, B extends string> = T & { readonly __brand: B };
type ConferenceId = Brand<string, "ConferenceId">;
type TicketId = Brand<string, "TicketId">;
type AttendeeId = Brand<string, "AttendeeId">;
type TicketStatus = "reserved" | "paid" | "canceled";
class Ticket {
constructor(
public readonly conferenceId: ConferenceId,
public readonly attendeeId: AttendeeId,
private status: TicketStatus = "reserved"
) {}
get currentStatus(): TicketStatus {
return this.status;
}
pay(): void {
if (this.status !== "reserved") throw new Error("Can only pay reserved ticket");
this.status = "paid";
}
cancel(): void {
if (this.status === "paid") throw new Error("Paid ticket requires refund flow");
this.status = "canceled";
}
static reserve(conferenceId: ConferenceId, attendeeId: AttendeeId): Ticket {
return new Ticket(conferenceId, attendeeId);
}
}
// aggregate root
class Conference {
private reservedCount = 0;
public readonly tickets: Ticket[] = [];
constructor(
public readonly id: ConferenceId,
public readonly title: string,
public readonly capacity: number
) {
if (capacity <= 0) throw new Error("Capacity must be positive");
}
get reserved(): number {
return this.reservedCount;
}
reserveTicket(attendeeId: AttendeeId): Ticket {
if (this.reservedCount >= this.capacity) throw new Error("Conference is full");
if (this.tickets.some(ticket => ticket.attendeeId === attendeeId)) throw new Error(`Attendee ${attendeeId} already has a ticket`);
const ticket = Ticket.reserve(this.id, attendeeId);
this.reservedCount++;
this.tickets.push(ticket);
return ticket;
}
}Being honest, I like the results.
In production, this would obviously be more complex - different error types, perhaps a Result<T, Error> pattern, handling edge cases like sold-out conferences with expired reservations. But even with those additional requirements, this approach maintains its elegance.
async function handleReserveRequest(conferenceId: ConferenceId) {
const conference = await conferenceRepository.readById(conferenceId);
const ticket = conference.reserveTicket(auth.currentUser.id); // imagine that it is API that has authentication system
await conferenceRepository.save(conference);
return ticket;
}Nice and clean. The logic is sound, pure, and independent - it could work with different interfaces like APIs, UIs, CLIs, or even LLM tools nowadays. Business rules are explicit and encapsulated, modeling real domain requirements. Testing is straightforward.
I wish I could write systems using this pattern exclusively.
But then the real world knocks on the door with messages like: "Hey, we have overbooking - the conference had 2000 seats, but 2023 people got registered" Or "Hey, customers are complaining that we've lost their tickets" Or "Our database has hit its read throughput capacity"
This is where the complexity begins. The pattern forces us to load entire aggregates into memory, make modifications, then write all changes back to the database. For our conference example, we'd need to load all related tickets just to enforce the attendee uniqueness rule.
The typical senior developer response? "We need locking." For performance issues: "We'll add lazy loading to the ticket entity relations - let me write a few hundred lines of ORM configuration."
Whether we choose optimistic or pessimistic locking doesn't really matter - both bring significant tradeoffs. We end up building ORM magic to preserve the domain model's purity. Often, teams just accept the complexity because they need to ship features, but the core issue remains: we're adding significant system complexity solely to maintain theoretical domain model purity.
Because of that, many, I'd guess the majority, don't use such an approach. And instead we have "domain services" layers.
Domain Services Layers
This would be a perfect spot for an Uncle Bob-style rant... Picture him sitting in his robe on the terrace, ranting about those 1,200 lines of code in ConferenceService and 3,000 lines in ConferenceServiceTests. Massive chunks of code that read, save, validate, change, lock, handle errors - grrr, grrr, grrr.
Anyone who's worked in the industry has seen this pattern and knows exactly what I'm talking about. Here's how our handler might look using this service approach:
class ConferenceService {
async handleReserveRequest(conferenceId: ConferenceId) {
return await unitOfWork.wrap(() => {
await conferenceService.trackReservation(conferenceId);
const ticket = await ticketsService.reserveTicket(conferenceId, auth.currentUser.id);
return ticket;
});
}
}For the sake of keeping the code size sane I'm not going to demonstrate the implementation of services. It should be pretty straightforward, and nowadays it is pretty simple to get the implementation from LLMs if needed. The approach itself is used pretty often, so I think a picture of a possible implementation here should quickly appear in the head.
Don't get me wrong - this approach works, and the concept is straightforward to understand. You group related work in a ServiceClass, trying to keep it free from input/output concerns (like API request/response handling). Inside, you use abstractions over storage, external APIs, queues, etc. to get the work done. It's reusable - once an operation is implemented, it can be called from other places, making it SOLID-compliant. It's testable to some degree - not perfect, but testable. You can either create 1-20 mocks and verify that everything is called with the right parameters, or go with integration tests where most code isn't mocked, except perhaps some low-level third-party dependencies.
Honestly, I'd probably use this approach myself, occasionally sighing about the size of those services and the mixed responsibilities within them. But generally speaking, it's not terrible for everyday development work.
So why not use this service approach?
The answer is LLMs. Trying to maintain this service approach with large-scale code generation by LLMs could result in something even worse than what I described above. This approach is highly unconstrained, so without proper human evaluation and rework after each LLM iteration, you end up with a total mess.
When I talk about LLMs here, I mean scaled output generation - typically using LLM agents like claude code or codex cli trying to deliver complete features or fixes, rather than step-by-step augmented generation like code snippets in chat conversations or IDE completions. With those interactive tools that focus on isolated context, issues are less likely to compound since the human developer stays heavily in the loop, making micro-corrections as they go.
But all the Twitter people are saying that we need to perform X10..0..0..0? faster now and I think without autonomous or semi-autonomous agents, it is not so easy to achieve this goal.
And I started to think, apart from custom lint rules and code scanning with a focus on building a "cage" for LLM.
By "cage" I mean a set of rules or constraints that don't limit the LLM's ability to generate code but automatically guide it toward the right approach for the specific project.
Think of it as a combination of lint rules, code verifications, and tests that enforce architectural patterns - all running automatically and reporting problems that demand solutions. For instance: duplication detection that fails the build after a specific threshold, forcing the LLM to refactor and de-duplicate any code it has added.
The key question is whether I can leverage the compiler to both force LLMs to follow good patterns while also helping human developers (bio-organisms) read and maintain code more easily. All while keeping in mind the technical characteristics required for building distributed systems.
So I was trying to build a middle-ground approach, which would not require a lot of quirks to keep the "purity" but at the same time, would model the domain a little bit better than with xxService architecture.
Requirements for results
Stricter architecture, with bigger separation of concerns than typical xxService architecture has. Clearly defined system that would empower automating architecture following rules (aka fitness functions). For instance, if I want to write a lint rule, that would enforce usage of a specific layer of types as parameters to another layer and protect others from being there. A typical example is to avoid usage of entities for the API layer, but here I'd like to have more low-level code rules. Utilization of a typing system to guide and control code generation. More "sound" domain modeling. Improve how domain is expressed in code, to the degree that tradeoffs with complexity and quality attributes are not needed.
State-based models
The main idea, is to have a layer in the model that represents not only high-level domain entity, like Ticket but be able to express on the type level state of the entity, or even express combined states in some cases.
Let's say a user wants to pay for the Ticket. We know that we can pay only for a ticket in reserved state, but we should not allow to pay for the ticket if it is already paid.
In the service-layered approach we would do something like this:
class TicketService {
async pay(ticketId: ticketId, paymentMethod: PaymentMethod): Promise<void> {
const ticket = await ticketRepository.findById(ticketId);
if (!ticket) throw new Error('Ticket not found');
if (ticket.state === 'paid') throw new Error('Ticket is already paid');
if (ticket.state !== 'reserved') throw new Error('Ticket is not reserved');
const updateResult = await ticketRepository.update({ state: 'paid' }, { where: { id: ticket.id, state: 'reserved' }});
if (updateResult.affected === 0) throw new Error('Marking ticket as paid failed');
}
}If we need to implement other operations that work with payable tickets, the validation logic would be nearly identical. As good developers, we wouldn't copy-paste this code - we'd extract it into a function like ensureTicketIsPayable() and call it wherever needed.
The challenge becomes remembering to call ensureTicketIsPayable() before every operation that requires the ticket to be in a reserved state. This is exactly the kind of thing that's easy to miss - especially for an LLM generating code at scale.
So what if we instead derive a type that represents a ticket that is in reserved state?
TypeScript is very handy here, as it could help us to express it almost effortlessly.
type ReservedTicket = Ticket & { state: 'reserved' };Now we can create a function that constructs such a ticket:
class TicketsBag {
async getReservedTicket(ticketId: string): Promise<ReservedTicket> {
const ticket = await ticketRepository.findById(ticketId);
if (!ticket) throw new Error('Ticket not found');
if (ticket.state !== 'reserved') throw new Error('Ticket is not reserved');
return ticket;
}
}Now we can create a function that takes a ReservedTicket as an argument and performs the payment operation:
async function pay(ticket: ReservedTicket, paymentMethod: PaymentMethod): Promise<void> {
const updateResult = await ticketRepository.update({ state: 'paid' }, { where: { id: ticket.id, state: 'reserved' }});
if (updateResult.affected === 0) throw new Error('State conflict: marking ticket as paid failed due to outer changes');
}The method now avoids most of the checks, as they are expressed within the parameter that says, "I accept only reserved tickets". And it is up to the caller to find how to construct this type when payment is needed.
Let's add a bit of the system
Since we are controlling a very eager junior developer, LLMs, we need to add a bit of the system to be able to implement minimal guard rails to use it correctly.
I was trying to find appropriate layers from the clean architecture, or other architectural patterns, like an entity component system (ECS) but didn't find one that would fit freely, so then I'd define the following terminology for these layers
- Entities - are left as is, it is our ground to map a data model
- Repositories - are responsible for persisting and retrieving entities from the data store. Pretty much common, do not see a need to invent anything special here.
- Variants - more precise shapes of domain states, like
ReservedTicketin the example above. It could be a more narrow type on top of entities, or custom state that represents domain state, or technical primitive. I think it could be even a compound type from different states, which are tied together because they are atomic for a specific business operation - Resolvers - a layer on top of variants and repositories, or other data sources (API clients) that construct variant, being safe that raw data complies with a defined variant. For example
TicketsBagin the example. - Capabilities - a layer on top of variants and repositories that could describe capabilities available to execute within broad domain terms. It could be a class that groups such actions like
PaymentCapabilities { markTicketAsPaid, refundTicket }or one time capability likeReserveConferenceTicket { performReservation }. What is important is that it works mostly with variants as input, instead of entities directly, which facilitates choosing a variant and resolver to construct it, and state correctness validations before operations.
As you might see, those layers define rules of communication, so it is something we can use to build around validation, that rule is followed. Which will help LLMs to self-correct in case of mistakes.
Conclusion
This domain type system approach offers a middle ground between the purity of traditional DDD aggregates and the pragmatic flexibility of service layers. By leveraging TypeScript's type system to encode domain states and business rules directly into type definitions, we create natural guard rails that guide human developers and constrain AI code generation alike.
Here are the key benefits I've observed in my real-world experiments:
For AI-assisted development: LLMs are forced to construct the correct variant types before performing operations (well, at least they try to 😛), which significantly reduces logic errors like mixing up entity IDs or applying operations to invalid states. The type system becomes a compile-time safety net, catching many issues that would otherwise require careful human review and rework.
For human maintainability: The code becomes genuinely self-documenting. When you see a function signature like pay(ticket: ReservedTicket), the preconditions are immediately clear. Business logic lives in the types rather than being buried in runtime checks scattered throughout service methods.
For performance and reliability: Unlike traditional DDD aggregates, this approach doesn't lock you into pessimistic locking or force you to load entire object graphs. You can still leverage database-level constraints, optimistic concurrency control, and efficient queries while retaining the benefits of domain modeling.
Is this approach perfect? Absolutely not. It requires more upfront investment in type modeling, and teams need to understand the conventions around variant construction and capability design. But for teams working with AI code generation at scale - or those who want domain modeling benefits without traditional DDD's performance penalties - it presents a compelling alternative.
The real test will be whether this scales in larger codebases and whether these type-driven constraints actually improve AI code quality in practice. But early results are promising: when you make invalid states unrepresentable in the type system, both humans and AIs tend to write more correct code by default.