Signal Notes
programming

TypeScript Practices That Make Product Code Easier to Change

Practical TypeScript boundaries for unknown data, domain states, configuration, and maintainable application code.

Published January 20, 2025Updated July 27, 20263 min read

TypeScript creates value when it describes real states and forces uncertain data through explicit boundaries. It creates noise when types merely repeat an API response or hide uncertainty behind assertions.

These practices focus on changeability rather than cleverness.

Treat external data as unknown

Network responses, environment variables, form input, and parsed files begin outside the type system. They should enter as unknown, then be validated.

import { z } from "zod";

const accountSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  status: z.enum(["active", "paused"]),
});

export async function fetchAccount(id: string) {
  const response = await fetch(`/api/accounts/${id}`);
  if (!response.ok) throw new Error("Account request failed");

  return accountSchema.parse(await response.json());
}

The official TypeScript handbook describes unknown as the safer counterpart to any because it requires narrowing before use. Runtime validation completes that boundary. A type annotation alone cannot make JSON valid.

Model states, not flags

Several booleans often permit combinations that make no sense:

type RequestState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: Account }
  | { status: "error"; message: string };

This discriminated union makes illegal states harder to express. A successful state always carries data. An error always carries a message. Rendering becomes an exhaustive decision instead of a collection of loosely related checks.

function describe(state: RequestState): string {
  switch (state.status) {
    case "idle":
      return "Ready";
    case "loading":
      return "Loading";
    case "success":
      return state.data.email;
    case "error":
      return state.message;
  }
}

Use satisfies for configuration

The satisfies operator checks a value without widening away its useful literal types:

type RoutePolicy = {
  access: "public" | "admin";
  cacheSeconds: number;
};

const policies = {
  home: { access: "public", cacheSeconds: 3600 },
  lobby: { access: "admin", cacheSeconds: 0 },
} satisfies Record<string, RoutePolicy>;

This is particularly effective for design tokens, route maps, feature configuration, and test fixtures.

Keep domain types independent

Database entities are persistence shapes. UI props are presentation shapes. Neither should automatically become the domain model.

interface ArticleSummary {
  slug: string;
  title: string;
  publishedAt: Date;
}

interface ArticleRepository {
  listPublished(): Promise<ArticleSummary[]>;
}

The interface expresses what the use case needs. A Prisma repository and an in-memory test repository can both implement it. That separation keeps tests fast and makes infrastructure replaceable.

Prefer narrow, inferred functions

Avoid large generic utility layers until repetition proves the need. Types are clearest close to the business rule:

export function canPublish(article: {
  title: string;
  content: string;
  sources: string[];
}) {
  return (
    article.title.trim().length >= 5 &&
    article.content.trim().length >= 200 &&
    article.sources.length > 0
  );
}

The return type is inferred. The input is deliberately smaller than a full database object.

Turn strictness on

The TypeScript strict option enables a family of checks that reveal uncertainty earlier, including strict null handling. New projects should start strict. Existing projects can migrate boundary by boundary, but an untracked mix of strict and loose code quietly transfers cost to runtime debugging.

The useful question is not how advanced a type is. It is whether the type makes the next change safer and more obvious.

Sources