Paul Wade.dev
NOW READING · TYPES THAT STOP AT THE WALL
All posts

Types that stop at the wall

Obstacle 47 min read

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.

typescript
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 differ

Parse brands at the walls, not sprinkled through every helper:

  • HTTP: after ValidationPipe / Zod, map dto.orderId through asOrderId.
  • DB: when a row comes back, brand row.id once in the repository mapper. Do not brand inside every service call.
typescript
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.

typescript
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:

typescript
@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:

typescript
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:

typescript
@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.

typescript
@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

boundarystop doingdo instead
HTTP intreat Body as domainDTO → brand → command
HTTP outreturn entity / anyresponse DTO from a mapper
DB inpass request objects to Prismaids, write models
DB outhand raw rows to controllerstoOrderView / Result
service corethrow HTTP exceptionsResult 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 as casts
  • Find/update paths return Result or T | null with a named miss — not "throw NotFound deep in the domain"
  • Controllers map Result → status; services do not import @nestjs/common for 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
  • any and as unknown as Order at 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