TypeScript on the backend: why your shared interfaces are a lie

4 min read

A messy, dimly lit developer workspace with multiple monitors displaying code.

The Shared Interface Delusion

Using a single shared/types.ts directory to sync your React frontend and Node.js backend is a convenience that masquerades as architecture. It is a lie. When you define a User interface in a shared package, you are making a dangerous assumption: that the data structure arriving from the network will match your local type definition. TypeScript only guarantees type safety within your compilation boundary, not at the runtime edge. When a junior dev at a firm like HMRC adds an optional field to a database schema but forgets to update the shared interface, your build succeeds, your tests pass, and your production logs explode with undefined errors.

TypeScript on the backend: why your shared interfaces are a lie comes down to the fundamental disconnect between static analysis and runtime reality. You are building a system that trusts an external actor—the user, a third-party API, or even your own frontend—to adhere to a contract that isn't enforced at the boundary. The moment the data hits your controller, the type system has already abandoned you, leaving your backend to process untrusted objects as if they were perfectly typed objects.

The Runtime Boundary Failure

Most backend developers rely on Zod or Joi, but they do it wrong. They define an interface, then write a schema, then try to sync them. This leads to double-typing, which is exactly what the 'shared interface' approach tries to avoid. If you aren't deriving your types from your runtime schemas, you are maintaining two sources of truth. When those sources drift—which they will—the compiler will never complain, and your runtime will begin to fail silently.

Why Type Predicates Aren't Enough

Using isUser(data) type predicates is a fragile workaround that clutters your domain logic with validation noise. It forces developers to write boilerplate guards for every incoming request. If you are doing this, you are effectively turning your backend into a giant, manual checking machine instead of a type-safe system. You should be using tools that enforce structural integrity at the entry point of your request-response cycle.

Stop Relying on Type Assertions

Too many teams use as User to quiet the compiler when handling response bodies. This is essentially saying 'I don't care if this is a User, just treat it like one.' If you cast an object that is missing a required property, TypeScript won't stop you from accessing that property later. You end up with TypeError: Cannot read properties of undefined, which is exactly the kind of mess that makes people hate Node.js in high-reliability environments like the NHS Digital infrastructure.

// The wrong way - relying on shared interfaces
interface User { id: string; email: string; }

app.post('/user', (req, res) => {
  const user = req.body as User; // This is a lie
  console.log(user.email.toUpperCase()); // Might crash if email is missing
});

// The right way - runtime validation deriving types
import { z } from 'zod';

const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
});

type User = z.infer<typeof UserSchema>;

app.post('/user', (req, res) => {
  const result = UserSchema.safeParse(req.body);
  if (!result.success) return res.status(400).json(result.error);
  console.log(result.data.email.toUpperCase()); // Guaranteed safe
});

Architecting for Boundary Integrity

To actually solve this, you need to treat your backend API layer as an untrusted border. Any data entering the system must be normalized against a schema before it ever touches your business logic. If your frontend and backend truly need to share types, generate them automatically from your OpenAPI specs or your database schema, not from a manually curated types.ts file. This creates a single source of truth that is machine-enforced.

Consistency is key for projects scaling like those at Monzo or GOV.UK. If you use a monorepo, utilize nx or turborepo to enforce that any change to a shared schema forces a re-run of your validation tests across the entire stack. Stop pretending that imports are enough. If you aren't validating the shape of the data at the exact moment it crosses the wire, you aren't doing type-safe programming—you are just guessing.

FAQ

Is it ever okay to share interfaces?

Only if they are generated automatically from a single source of truth like a database migration or an OpenAPI specification. If you are typing them by hand in two places, you are guaranteed to have drift.

Why is Zod preferred over manual interface definitions?

Zod allows you to define a schema once and infer the TypeScript type from it. This ensures the runtime validation logic and the static type definitions are always in sync.

Does runtime validation impact performance?

The overhead is negligible in a standard Node.js backend. The cost of parsing JSON is significantly higher than the cost of validating it against a schema. You are already paying the parsing cost; the validation is a necessary trade-off for reliability.