Skip to content
DevSaheb

technology

TypeScript that makes the wrong thing impossible to write

TypeScript is easy to adopt and easy to waste. A codebase can be entirely annotated and still crash on a missing field, because the types were written to describe what the code already does rather than to rule out what it should never do. The value is not in labelling variables — it is in making invalid states unrepresentable, and in catching a change to a shared contract before anyone runs it.

The compiler settings decide most of it

A TypeScript project with `strict` off is a linter with extra steps. We turn on the strict family, plus the ones people leave off: `noUncheckedIndexedAccess`, so reading past the end of an array is `T | undefined` rather than a confident lie, and `exactOptionalPropertyTypes`, so an optional property and one explicitly set to undefined stop being the same thing.

Those two produce a burst of errors in an existing codebase, and each error is a place the code was already wrong. We turn them on early, when the fixing is cheap, rather than leaving them as an upgrade nobody schedules.

`any` is the other one. It is occasionally the right tool, and it should be a deliberate, commented decision — not something a library's untyped export spreads through the call graph unnoticed.

Types stop at the boundary unless you make them not

The compiler guarantees nothing about data arriving at runtime. An API response cast to an interface is a promise, not a check, and the moment the server changes a field the browser fails somewhere far away from the cause.

So every boundary — HTTP responses, form input, environment variables, anything parsed from JSON — goes through a runtime validator, and the static type is derived from that validator rather than written alongside it. One definition, two uses, no chance of the two drifting apart.

Environment variables are the case people skip and regret. A missing variable should stop the process at boot with a message naming it, not surface as undefined in a database URL an hour later.

Modelling states instead of flags

The most valuable typing decision in an application is usually how it represents what is happening. Four independent booleans for loading, error, data and empty permit combinations that make no sense, and every consumer has to check them in the right order.

A discriminated union makes those combinations unspeakable, and the compiler then requires each case to be handled. The payoff arrives later: adding a fifth state produces errors at every place that must change, instead of a quiet fall-through that renders nothing.

TypeScript · src/lib/checkout.ts

One definition, two uses. The schema validates at the boundary and the static type is inferred from it, so a server-side change cannot pass unnoticed. The union makes the impossible states — paid but no reference, failed but no reason — impossible to construct, and the exhaustiveness check turns a new state into a compile error rather than a blank screen.

import { z } from 'zod';

// Validator first, type derived from it. Two hand-written declarations drift;
// this pair cannot.
const checkout = z.discriminatedUnion('status', [
  z.object({ status: z.literal('pending'), expiresAt: z.string().datetime() }),
  z.object({ status: z.literal('paid'), reference: z.string().min(1) }),
  z.object({ status: z.literal('failed'), reason: z.string(), retryable: z.boolean() }),
]);

export type Checkout = z.infer<typeof checkout>;

export async function loadCheckout(id: string): Promise<Checkout> {
  const res = await fetch(`/api/checkout/${id}`);
  if (!res.ok) throw new Error(`Checkout ${id}: ${res.status}`);
  // Not `as Checkout`. A cast is a promise; this is a check.
  return checkout.parse(await res.json());
}

export function describe(c: Checkout): string {
  switch (c.status) {
    case 'pending':
      return `Awaiting payment until ${c.expiresAt}`;
    case 'paid':
      return `Paid — ${c.reference}`; // reference exists only in this branch
    case 'failed':
      return c.retryable ? `Failed: ${c.reason}. Try again.` : `Failed: ${c.reason}`;
    default: {
      // Add a fourth status and this line stops compiling, which is the point.
      const unreachable: never = c;
      return unreachable;
    }
  }
}

Who works on this

  • Nayemul Saheb

    CEO & Software Engineer

    Sets the compiler strictness and the typed boundaries we hold to.

The rest of the team

When it is not worth it

A script that runs once, a prototype you intend to throw away, or a build step of forty lines does not need a type system — the ceremony would outweigh the benefit. TypeScript pays off in proportion to how long the code lives and how many people touch it. If the honest answer is that this will be deleted next month, JSDoc annotations on the tricky functions will give you most of the editor support with none of the setup.

Common questions

Can we migrate an existing JavaScript codebase gradually?
Yes, and gradually is the right way. Turn on checking file by file, start at the boundaries where data enters, and treat the error count as a backlog rather than a blocker. The usual mistake is enabling strict mode everywhere on day one, generating two thousand errors, and turning it back off.
Does TypeScript slow the team down?
For the first few weeks, a little. After that it is faster, mostly because rename and find-references become reliable and reviews stop spending time on whether a field can be null. The place it genuinely costs time is fighting a badly typed third-party library, which is a reason to choose dependencies partly on the quality of their types.
Do types replace tests?
No. Types rule out whole categories of mistake — wrong shape, missing field, unhandled case — and say nothing about whether the logic is correct. They reduce the number of tests worth writing rather than the need for them.
What about the runtime cost?
None. Types are erased at build time; the JavaScript that ships is the same JavaScript. The only runtime cost is the validation we deliberately add at boundaries, which is the part that was never free in any language.

Related services

Built with

Tell us the constraint. We will tell you honestly if we fit.