All posts

Where Your Types Actually End

Published Feb 26, 2026 · Martín Fernández

Most TypeScript teams believe they have type safety. They're half right.

Inside a single file, the compiler catches mistakes. Across a package boundary, a service call, a message broker — types evaporate. An API publishes an event with a field the consumer doesn't expect. A frontend assumes a status value the backend stopped sending two weeks ago. A message broker routes an event no one is listening for. These aren't edge cases. They're the normal failure mode of systems that are typed in the small but untyped in the gaps.

In Context is King, we made the case for the monorepo as a way to expand the context radius. But context without contracts is just visibility. You can see the whole system and still ship a broken interface. What closed the gap was making every boundary between every layer a type the compiler can check.

What "fully typed" actually means

Most bugs don't live inside a single file. They live in the spaces between services. A fully typed system extends compile-time safety across those spaces. Every contract that crosses a package, a service, or a network call is expressed as a type that the compiler can check. If the shape changes somewhere, everything that depends on it breaks — at build time, not in production.

This is not a framework. It's a property of how we structured Dap: a TypeScript monorepo where shared packages define the contracts, and the compiler enforces them across the full dependency graph.

The foundation: schemas that generate types

Every event in Dap starts as a Zod schema. The TypeScript type is derived from it, not written separately.

// packages/events/src/schemas.ts
export const sessionCompletedEventSchema = baseEventSchema.and(
  z.object({
    eventType: z.literal("session.completed"),
    payload: z.object({
      sessionId: z.number(),
      status: z.union([
        z.literal("SUCCESS"),
        z.literal("FAILED"),
        z.literal("CANCELLED"),
        z.literal("TIMEOUT"),
      ]),
    }),
  }),
);

The TypeScript type is inferred: type SessionCompletedEvent = z.infer<typeof sessionCompletedEventSchema>. One definition. The schema validates at runtime boundaries. The inferred type enforces at compile time. They cannot drift because the type is the schema.

Schemas compose across packages. Agent activity types defined in @crunchloop/dap-agent-core are imported and composed into session event schemas in @crunchloop/dap-events. The type system tracks the full structure through every composition.

Type-safe publishing: the EventMethods registry

The event bus doesn't accept arbitrary payloads. A registry maps every event type to its expected shape:

// packages/event-bus/src/routing.ts
export type EventMethods = {
  "session.created": { payload: SessionCreatedEvent["payload"] };
  "session.completed": { payload: SessionCompletedEvent["payload"] };
  "artifact.created": { payload: ArtifactCreatedEvent["payload"] };
  "tunnel.requested": { payload: TunnelRequestedEvent["payload"] };
  // ... 20+ event types
};

The publish method is generic over this registry:

async publish<T extends keyof EventMethods>(
  eventType: T,
  payload: EventMethods[T]["payload"],
): Promise<void>

If a producer deep in the stack publishes "session.completed", TypeScript enforces that the payload includes a sessionId and a status that is one of SUCCESS, FAILED, CANCELLED, or TIMEOUT. Pass the wrong shape and the code doesn't compile.

This is where the monorepo earns its keep. The EventMethods registry imports types from @crunchloop/dap-events, consumed by every application in the repo. Change the payload shape in the schema, and the compiler flags every producer and consumer that breaks — immediately, across the entire graph.

Type-safe consumption: routing keys that infer types

The consumer side does something more interesting. When you subscribe to routing keys, TypeScript infers what event types you'll receive:

// packages/event-bus/src/event-consumer.ts
consume<const K extends readonly string[]>(
  keys: K,
  ..._enforce: [InvalidKeys<K>] extends [never]
    ? []
    : ["Invalid routing key(s):", InvalidKeys<K>]
): Observable<RoutingKeysToEventTypes<K>>

Subscribe to ["sessions.123.created", "sessions.123.completed"] and you get an Observable<SessionCreatedEvent | SessionCompletedEvent>. The type is inferred from the routing key patterns. Pass an invalid key and the code doesn't compile — the _enforce parameter generates a type error naming the specific invalid key.

No unknown. No runtime type guards on every message. The routing key is the type contract.

The chain — and where it frays

The SSE endpoint maps client subscriptions to typed routing keys:

// apps/api/src/stream/stream.controller.ts
events(@Body() payload: CreateStreamEventRequestDto): Observable<MessageEvent> {
  const routingKeys = subscriptionsToRoutingKeys(payload.subscriptions);
  return this.eventsConsumer.consume(routingKeys).pipe(
    map((event) => ({
      id: event.eventId,
      type: event.eventType,
      data: event,
    })),
  );
}

The API generates an OpenAPI spec from its NestJS decorators. The @crunchloop/dap-api-client package auto-generates a typed SDK from that spec — including a discriminated union of every SSE event type. The types travel from Zod schema to generated client without a gap.

In theory.

The code generator bug. When we wired up the generated SDK in our frontend hooks, event.data inside the onSseEvent callback was typed as StreamEvent<unknown>. Not the discriminated union we'd carefully defined — just unknown. Every hook had to cast its way through:

// What we had to write — every hook, every event
const eventData = event.data as { eventType: string };
if (eventData.eventType === "tunnel.requested") {
  const payload = (eventData as TunnelRequestedEvent).payload;
}

We initially assumed this was a TypeScript limitation — that the compiler couldn't narrow a discriminated union inside an async callback. It wasn't. The problem was in the code generator. The SDK's Options type forwarded two generic parameters to the underlying client — TData and ThrowOnError — but not TResponse. For regular HTTP endpoints, the response type is inferred from the return value. For SSE endpoints, where events arrive through a callback, the response type needs to flow through the generic chain to reach onSseEvent. It didn't.

We patched the library locally and reported it upstream. With the fix, onSseEvent receives the full discriminated union, and switch/case narrowing works as expected:

// After the patch — event.data is properly typed
onSseEvent: async (event) => {
  switch (event.data.eventType) {
    case "artifact.created": {
      const payload = event.data.payload; // ArtifactCreatedEvent["payload"]
    }
  }
}

One missing generic parameter in the code generator. Fifteen event types. Every SSE callback in every hook collapsed to unknown. The types were defined correctly at every layer — schema, event bus, API, OpenAPI spec — and a single gap in the codegen tooling erased them at the last mile.

The spec limitation. Even with the code generator fixed, there's a gap that no patch can close. OpenAPI defines a single response type for the SSE endpoint: a discriminated union of all fifteen event types. When a hook subscribes to only artifact events, the onSseEvent callback still receives that full union:

// useSessionArtifacts subscribes to artifact.created only
subscriptions: [{ eventType: "artifact.created", filters: { id: sessionId } }],
// But event.data is still typed as the full union:
// SessionCreatedEvent | SessionUpdatedEvent | ... | TunnelDisconnectedEvent

OpenAPI has no mechanism to express "these subscription inputs produce this subset of response types." The spec describes the endpoint's full contract, not a per-request narrowing of it. At runtime, the server only sends events matching the subscription. At the type level, TypeScript can't know that — because the spec doesn't encode it.

This isn't a bug to file. It's a fundamental mismatch between the expressive power of the specification and the relationship we want to describe. The subscription narrows the events at runtime; the type system can't follow that narrowing because the spec wasn't designed for this kind of input-output correlation.

Six packages. One type. Two seams — one we patched, one that's structural.

Both are worth naming. The code generator bug shows that "fully typed" depends not just on your code, but on every tool in the chain generating correct types. The spec limitation shows that some boundaries can't be typed because the contract language itself lacks the vocabulary. The infrastructure carries the types end-to-end, but the last mile depends on tooling that can break and specifications that can't express everything you need.

Why this matters more for agents

An experienced developer carries implicit knowledge about contracts — which services depend on which events, what a field rename would break, where the assumptions live. An AI agent has none of that. It sees the code in front of it and reasons locally.

In an untyped system, an agent can rename a field in an event schema, see that the file compiles, and move on. The breakage shows up three services away, in production, a week later. The agent didn't know about the dependency because nothing in the code made it explicit.

In a fully typed system, the compiler surfaces the dependency graph immediately. The agent changes the schema, runs the build, and gets a list of every file that broke — across every package. The feedback isn't "something went wrong somewhere." It's "these 14 consumers expect a field that no longer exists, and here's where."

This is the same benefit humans get. But it matters more for agents because they don't carry the tacit knowledge that experienced developers accumulate over months of working in a codebase. Types encode what would otherwise live only in human heads: the implicit contracts, the assumed shapes, the dependencies that no one documented because everyone just knew.

The monorepo made the full system visible to agents. The type system made the contracts legible to the compiler. Together, they turn a silent drift into a loud failure — at the only moment when it's cheap to fix.

The tradeoffs

The same tight coupling that catches bugs at compile time has a weight you feel every day.

Regeneration costs. The API client is auto-generated from the OpenAPI spec. Every schema change triggers a regeneration cycle: modify the schema, rebuild the events package, rebuild the API, regenerate the spec, regenerate the client, rebuild the frontend. The dependency chain is real, and it compounds.

Cascade rebuilds. A type change in @crunchloop/dap-events can trigger rebuilds across every package that depends on it — and that's most of the system. Turborepo's dependency-aware caching helps, but when the change is in a widely-shared package, there's no shortcut. The system pays the cost of its own connectivity.

Coupling as a feature and a liability. Tight type coupling is the whole point: it makes contract violations visible at compile time. But it also means that what should be a small, local change can turn into a cross-system rebuild. The same property that prevents production bugs makes the development feedback loop slower.

Schema evolution complexity. Adding a field is easy. Removing one or changing a type requires updating every producer and consumer simultaneously. In a multi-repo setup, you'd version the schema and let consumers migrate at their own pace. In the monorepo, the migration is all-or-nothing. That's safer but more work upfront.

The question

A type system inside a single application is table stakes. The harder question is whether it's worth extending that system across every boundary in your architecture — and paying the build costs, the coupling, and the all-or-nothing migrations that come with it.

For us, the answer keeps being yes. Not because the tradeoffs aren't real — every cascade rebuild is a tax we pay for the guarantee that the system is consistent. But because the alternative is worse: runtime errors from contracts that drifted silently, discovered in production instead of in the build.

But "fully typed" turns out to be an aspiration, not a binary. There's always another seam — another boundary where the types get close but don't quite connect. The question isn't whether you've eliminated every gap. It's whether the gaps you have left are ones you chose, not ones you didn't notice.