Published on

Type Safety Is a Cross-Layer Chain: Strict Mode Only Holds the Inside of the Chain

Authors
  • avatar
    Name
    Jack Qin
    Twitter

A lot of people's understanding of type safety stops at "I turned on strict mode." But strict mode only holds the self-consistency inside one layer — it can guarantee you won't use a string as a number within this file, but it can't govern whether the promises at every boundary are honored as data flows from the database to the component.

And bugs are precisely not inside a layer; they surface at the boundaries. The real value of type safety is to align "the shape I return" and "the shape you assume" at compile time — it's a chain running from the database all the way through to the component boundary, and any loose link produces a bug right at that link's junction. What I want to take apart here is how this chain breaks on the frontend and backend sides respectively.

Why types must be discussed across layers

The greatest value of types isn't self-consistency within a layer, but whether cross-layer promises can be honored. A type system that's strict within a layer but lets go at the boundary has abandoned protection at exactly the place errors are most likely.

The most common way the frontend breaks this: some hook passes the backend's raw DTO straight through, or simply returns any, on the grounds that "it runs anyway." The cost of this move is invisible — the consumer has no idea what the data looks like, and worse, when the backend renames a field, there's no compile-time signal to flag "the frontend breaks here." Types are supposed to light up red on exactly this kind of cross-layer change; any switches the light off.

The backend's way of breaking it is more structural: a module, for convenience, references another module's implementation project (rather than its Contracts project), or returns an EF entity straight from an endpoint. The former punches through the module boundary; the latter leaks persistence details to all consumers — frontend, generated client, all forced to know the database's internal structure.

So type safety has to be discussed for frontend and backend together: the frontend wires the chain on the client side with typed APIs, typed mapping, and typed hooks; the backend wires it on the server side with explicit contracts, strong module boundaries, and the double constraint of compile-time plus architecture tests.

Frontend: strict mode + feature-local types + a fully-typed pipeline

Repo rules:

  • apps/web/tsconfig.json enables strict mode.
  • @/* resolves to src/*.
  • Feature-local types are colocated with the feature that owns them.
  • Prefer typed API responses and typed hooks over untyped transformation chains.
  • Test files have looser lint than application code, but application code should still avoid any.

Type organization: shared application-level types live near the library that owns them; feature-local types live in the feature. For example, the feature types for email-schedules live in its own types directory, while the generics of DataTable live in the shared component. Use colocated types, not one giant global type bucket — a giant type bucket makes "who owns this type, who's affected by changing it" impossible to answer.

Validation comes from a fully-typed pipeline, not a runtime schema: the frontend's type safety today comes mainly not from some runtime schema library but from an end-to-end typed chain — typed API client usage, typed DTO-to-model mapping, typed query hooks, plus backend contract generation + drift checks. For instance, the weekly-report hook maps the DTO to a model before exposing data; the contract workflow in CI enforces a check on drift between the API schema and the client. Every link of this chain does the same thing: when data crosses a boundary, the type crosses with it, rather than getting flattened to any at the boundary.

Enforced ESLint rules (these aren't suggestions, they're rules):

  • @typescript-eslint/consistent-type-imports
  • @typescript-eslint/consistent-type-exports
  • @typescript-eslint/no-confusing-void-expression
  • @typescript-eslint/no-import-type-side-effects

Common patterns: prefer type imports/exports; let TS infer local return types unless an explicit annotation is clearer; use generics for reusable shared components; use typed query keys, a typed API client, and typed hook results.

Backend: a contract is a project, not a folder

The backend ensures type safety through explicit contracts, strong module boundaries, and compile-time constraints. There's a key wording worth pausing on: a contract is a project, not a folder.

Each module exposes its public data shapes through a dedicated *.Contracts project, e.g. AssetDto, DustLevelDto, EmailKind. Why a separate project and not a Contracts/ folder? Because a project boundary is something the compiler and architecture tests can enforce, whereas a folder boundary is only an organizational suggestion. Putting contracts in a separate project gives the constraint "other modules may only reference Contracts, not the implementation" an executable handle — a wrong implementation reference becomes a violation visible to the compiler/architecture tests, rather than a convention buried in a folder that anyone can route around.

Rules:

  • Other modules may only reference a module's Contracts project, not its internal implementation.
  • Contracts must contain no EF Core or host-level dependencies.
  • Don't leak persistence entities directly through an API endpoint or event.

These rules aren't held by humans alone — architecture tests enforce some of them.

Endpoints stay thin. An endpoint is only responsible for: route mapping, authentication and permission checks, converting the request model to a domain/application model, and returning an HTTP result. Don't move business rules, cross-module orchestration, or reusable persistence logic into endpoint classes — unless the module already does it that way and the logic really is route-local.

Shared abstractions hold only transport-/host-agnostic things. Platform.BuildingBlocks is for transport-agnostic, host-agnostic shared abstractions only, like Result.cs, ICommandDispatcher, IEventPublisher. Architecture tests enforce a few iron rules:

  • BuildingBlocks may not depend on the API host
  • BuildingBlocks may not depend on the Worker host
  • BuildingBlocks may not depend directly on MassTransit or EF Core

The shared logic of these three: once a shared abstraction depends on a concrete piece of infrastructure, it infects every module that references it with that infrastructure's "weight." The purer BuildingBlocks is, the wider the range over which it can be safely reused.

Nullability and explicitness: follow the C# explicitness practices the codebase already implies — make optional inputs and filter parameters nullable when the API genuinely allows omission; align DTO/property types with the real database and API contract semantics; prefer explicit contract records and DTOs over weakly-typed anonymous payloads crossing boundaries. Nullability must be honest: null should mean exactly "this field really can be omitted," not a cover for "I didn't think through whether there's a value here."

Counter-examples

// Counter-example 1 (frontend): hook returns any / passes through the raw DTO, the consumer has no idea of the shape
export function useWeeklyReport() {
  return useQuery({
    queryKey: ['weekly-report'],
    queryFn: async (): Promise<any> => {
      // ❌
      const res = await fetch('/api/v1/weekly-reports')
      return res.json() // ❌ raw DTO thrown straight out
    },
  })
}

// Correct: typed result + DTO-to-model mapping
export function useWeeklyReport() {
  return useQuery<WeeklyReport>({
    queryKey: weeklyReportKeys.detail(id),
    queryFn: async ({ signal }) => {
      const dto = await api.getWeeklyReport(id, { signal }) // returns WeeklyReportDto
      return mapWeeklyReportDtoToModel(dto) // ✅ UI-ready model
    },
  })
}
// Counter-example 2 (backend): endpoint returns the EF entity directly
group.MapGet("/assets/{id}", async (Guid id, AssetsDbContext db) =>
    Results.Ok(await db.Assets.FindAsync(id))); // ❌ persistence entity leaks into the contract
// Correct: return a DTO contract
group.MapGet("/assets/{id}", async (Guid id, AssetsDbContext db) =>
{
    var asset = await db.Assets.FindAsync(id);
    return asset is null ? Results.NotFound() : Results.Ok(asset.ToDto()); // ✅ AssetDto
});
// Counter-example 3 (backend): one module references another module's implementation project
// using Platform.Modules.Email;  ❌ should be using ...Email.Contracts

Other things to avoid:

  • Avoid any in application code, unless there really is no realistic usable type boundary.
  • Avoid unnecessary type assertions when a better type could propagate instead.
  • Avoid scattering types into unrelated folders when the feature already owns them.
  • Don't add infrastructure dependencies to BuildingBlocks or Contracts projects.
  • Don't blur the API/Worker/module boundaries already enforced by architecture tests.

Repo reminder: test files deliberately loosen several unsafe-type rules. Treat it as an exception for tests, not a standard for production code.

Putting it into practice

  1. Carry types from the boundary all the way down: the backend returns DTO contracts, the frontend maps the DTO to a model in the hook, and the component only touches the typed model.
  2. Cross-module only through Contracts: need another module's data, reference its *.Contracts project, never the implementation.
  3. Treat a contract change as a contract change: when an endpoint's shape changes, update the related tests and the generated client; CI's contract-drift check has your back.
  4. Nullability must be honest: make it nullable only when the API genuinely allows omission; don't use null to paper over semantics.
  5. any is a signal, not a tool: seeing any in application code, first ask "did I just skip a type boundary?"

The transferable layer

Strip away the specific syntax of TypeScript and C#, and the genuinely transferable insight of type safety is: the role of types is to turn cross-boundary promises into compile-time-verifiable facts, and every any, every passed-through raw entity, every implementation reference is quietly canceling that promise at some boundary. Strict mode holds the inside of the chain, but the chain breaks at the boundaries.

When designing any cross-layer data flow, instead of staring only at whether each layer is internally correct, walk the data through and ask at every junction: as it crosses this boundary, did the type cross with it, or did it get flattened to any here? Let the type travel the whole way from database to component, and bugs lose the crack they'd otherwise surface through at the boundary.