TypeScript promises compile-time safety, but teams often settle for weak types or excessive any usage. The real value emerges when you treat the type system as a design tool, not just a linter. After shipping TypeScript codebases to production, I’ve distilled practices that maximize safety without sacrificing velocity.

Start with strict mode

Enable strict: true in tsconfig.json from day one. This activates strictNullChecks, noImplicitAny, and other guardrails that catch common mistakes. Retrofitting strict mode into an existing codebase is painful; starting strict is liberating.

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true
  }
}

Model your domain with types

Types should mirror your domain logic. Instead of generic objects, create discriminated unions for state machines:

type LoadingState = { status: 'loading' };
type SuccessState = { status: 'success'; data: User };
type ErrorState = { status: 'error'; error: string };

type UserState = LoadingState | SuccessState | ErrorState;

function renderUser(state: UserState) {
  switch (state.status) {
    case 'loading': return <Spinner />;
    case 'success': return <Profile user={state.data} />;
    case 'error': return <ErrorMessage message={state.error} />;
  }
}

This pattern makes impossible states unrepresentable. The compiler ensures you handle every case.

Avoid type assertions

Type assertions (as) override the compiler. Use them sparingly, and only when you have runtime guarantees (e.g., after validation). Prefer type guards or unknown narrowing:

// ❌ Assertion bypasses checks
const user = data as User;

// ✅ Type guard validates shape
function isUser(data: unknown): data is User {
  return typeof data === 'object' && data !== null && 'id' in data;
}

if (isUser(data)) {
  console.log(data.id); // Safe
}

Leverage utility types

TypeScript ships with utility types like Pick, Omit, Partial, and Record. Use them to derive types instead of duplicating definitions:

type User = {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
};

type UserPreview = Pick<User, 'id' | 'name'>;
type UserUpdate = Partial<Omit<User, 'id' | 'createdAt'>>;

This keeps types DRY and automatically reflects upstream changes.

Measure type coverage

Use tools like type-coverage to track the percentage of your codebase with explicit types. Aim for >95% coverage on critical paths. Monitor trends in CI to prevent regressions.

TypeScript’s value compounds when teams commit to modeling domains accurately and avoiding escape hatches.

By enabling strict mode, modeling state machines, avoiding assertions, and leveraging utility types, your TypeScript codebase becomes a living specification that documents intent while catching bugs before they reach production.