Types that stop at the wall
Branded IDs and Result types keep HTTP and DB edges honest. Returning ORM entities from controllers dissolves the wall TypeScript thought you built.
TypeScript walls only work if values stop at them. Branded IDs and Result types mark those stops. Returning a Prisma or TypeORM entity from a controller dissolves the wall: the wire now speaks your schema.
I care about this because Nest apps often look fully typed while still shipping table rows over HTTP. The compile is green. OpenAPI picks up whatever you returned. Clients learn your column names. The wall was a feeling, not a stop.
Branded IDs
string is not an order id. It is also not a customer id. Mixing them compiles. That bug shows up as a quiet swap in a refund call — arguments in the wrong order, both strings, both "ids," TypeScript shrugs.
type OrderId = string & { readonly __brand: 'OrderId' };
type CustomerId = string & { readonly __brand: 'CustomerId' };
function asOrderId(raw: string): OrderId {
// validate format here if you want a runtime gate
return raw as OrderId;
}
function refund(orderId: OrderId, customerId: CustomerId) {
/* ... */
}
// refund(customerId, orderId) — fails typecheck when brands differParse brands at the walls, not sprinkled through every helper:
- HTTP: after ValidationPipe / Zod, map
dto.orderIdthroughasOrderId. - DB: when a row comes back, brand
row.idonce in the repository mapper. Do not brand inside every service call.
function toOrderView(row: { id: string; customerId: string; total: number }): OrderView {
return {
id: asOrderId(row.id),
customerId: asCustomerId(row.customerId),
total: row.total,
};
}Unbranded strings may exist in DTOs as wire format. Domain functions take brands. That split is the wall. In practice it feels like a little ceremony at the edge and a lot less guessing in the middle — especially when a function takes three ids and you are tired.
If you want a runtime gate too, put format checks inside asOrderId and call it only at those walls. Scattered as OrderId casts in random files are not brands. They are lies with better names.
Result / ok-err at the edges
Throwing for expected outcomes forces every caller into try/catch. A small Result type keeps success and failure in the signature. Prefer a clear yes/no at the boundary over a nested error object that mostly exists to look careful — operators and other programs should be able to tell what happened.
type Ok<T> = { ok: true; value: T };
type Err<E> = { ok: false; error: E };
type Result<T, E> = Ok<T> | Err<E>;
const ok = <T>(value: T): Ok<T> => ({ ok: true, value });
const err = <E>(error: E): Err<E> => ({ ok: false, error });HTTP edge — controller maps Result to status:
@Get(':id')
async get(@Param('id') id: string): Promise<OrderResponseDto> {
const result = await this.orders.findById(asOrderId(id));
if (!result.ok) {
if (result.error === 'not_found') throw new NotFoundException();
throw new BadRequestException(result.error);
}
return toResponseDto(result.value);
}DB edge — repository returns null or Result, not throw-on-miss:
async findById(id: OrderId): Promise<Result<OrderView, 'not_found'>> {
const row = await this.prisma.order.findUnique({ where: { id } });
if (!row) return err('not_found');
return ok(toOrderView(row));
}Reserve exceptions for bugs and infrastructure failure (connection down, unexpected invariant). Expected misses and rule violations stay in Result. Controllers own HTTP status. Services own vocabulary like 'not_found' | 'insufficient_funds'. That vocabulary travels to CLI jobs and workers without dragging Nest HTTP types along for the ride.
Libraries (neverthrow, custom helpers) are fine. The pattern matters more than the package: the type says failure is normal data. Clever wrapping that hides the outcome is not a feature.
ORM entities are not API types
This compiles and ships. It also couples clients to persistence:
@Get(':id')
find(@Param('id') id: string): Promise<Order | null> {
return this.prisma.order.findUnique({ where: { id } });
}Problems that follow — and they follow faster than you think once a frontend team generates types from the response:
- Response includes every scalar column, including ones you meant to keep internal
- Relations are absent unless you
include; clients learn to depend on whatever you included today - Nullability and Decimal/Json types leak generator quirks into OpenAPI
- Renaming a column renames your public JSON
- Soft deletes and audit fields show up unless you remember to strip them
The wall is a mapper. It looks like overhead the first week. It looks like mercy the week you rename a column and the mobile app does not break.
@Get(':id')
async find(@Param('id') id: string): Promise<OrderResponseDto> {
const result = await this.orders.findById(asOrderId(id));
if (!result.ok) throw new NotFoundException();
return {
id: result.value.id,
customerId: result.value.customerId,
total: result.value.total,
status: result.value.status,
};
}OrderResponseDto is the contract. Prisma's Order stays behind the repository. TypeORM's entity class with @Column stays there too.
Same rule upward: do not accept entities as @Body(). Bodies are DTOs. Entities are loaded by id inside the service. Accepting an entity-shaped body invites clients to send fields your persistence layer will happily write if you are not careful.
Where types must stop
| boundary | stop doing | do instead |
|---|---|---|
| HTTP in | treat Body as domain | DTO → brand → command |
| HTTP out | return entity / any | response DTO from a mapper |
| DB in | pass request objects to Prisma | ids, write models |
| DB out | hand raw rows to controllers | toOrderView / Result |
| service core | throw HTTP exceptions | Result or domain error union |
Checklist
- OrderId / CustomerId (or equivalent) brands exist and are used in service signatures
- Brands are applied in one mapper per wall, not with scattered
ascasts - Find/update paths return
ResultorT | nullwith a named miss — not "throw NotFound deep in the domain" - Controllers map Result → status; services do not import
@nestjs/commonfor control flow - No controller method return type is a Prisma model or TypeORM entity
- No
@Body()parameter type is an entity class - OpenAPI / clients generate from response DTOs, not from
entity.ts -
anyandas unknown as Orderat boundaries are treated as wall breaches in review
Why "the wall" is the right metaphor
Types are structural. They do not run at runtime unless you add a gate (validation, brand constructors, Result checks). A Nest app can look fully typed and still pass entities across HTTP because the entity satisfies the structural shape of a sloppy response type — or because the return type is the entity.
Brands make ids non-substitutable. Results make failure visible in the signature. Mappers make persistence non-exportable. Each one is a stop sign. Remove them and TypeScript still paints green while the wall is gone.
I have been on teams that treated the green compile as the finish line. It is not. The finish line is whether a bad id, a missing row, or a Prisma type can cross a boundary you meant to own. Build the stops on purpose. Then keep ORM types on the DB side of the line.
Related
- Nest skill is the boundary — don't return ORM entities from controllers
- Validation is a gate — ValidationPipe / Zod at the HTTP edge before brands