05 Strict TypeScript settings 6 chapters

Theme 01 · Type-Safety Maximalist, But Pragmatic

Strict TypeScript settings

Explanation

Strict TypeScript settings

Plain Human Explanation

Strict settings make TypeScript ask the questions a careful reviewer would ask: could this be missing, could this be the wrong shape, and did anyone prove it first? The goal is not to make coding painful. The goal is to catch uncertain values before users find them.

Technical Explanation

Settings such as strict, noImplicitAny, strictNullChecks, noUncheckedIndexedAccess, and exactOptionalPropertyTypes make TypeScript preserve uncertainty instead of quietly widening it away. Code then has to narrow unknown values, handle missing array items, and be clear about optional fields.

Why It Matters

  • User impact: fewer runtime crashes from missing data, wrong assumptions, or unhandled empty states.
  • Product behavior: code must handle the states the product can actually produce.
  • Risk: loose compiler settings let fragile code look safe during review.
  • Decision point: use strict settings as the default, then migrate old areas gradually when the current codebase is not ready.

The Core Move

Let the compiler keep uncertainty visible. Fix the code by narrowing values and modeling missing data, not by turning the warnings off.

Small Example

Strict TypeScript settings: Small Example

Bad TypeScript Example

type User = {
  email?: string;
};

export function emailFirstUser(users: User[]) {
  return users[0].email.toLowerCase();
}
type User = {
  email?: string;
};

export function emailFirstUser(
  users: User[],
) {
  return users[0].email.toLowerCase();
}

Good TypeScript Example

type User = {
  email?: string;
};

export function emailFirstUser(users: User[]) {
  const firstUser = users[0];
  if (!firstUser?.email) {
    return { ok: false, error: "missing-email" };
  }

  return { ok: true, email: firstUser.email.toLowerCase() };
}
type User = {
  email?: string;
};

export function emailFirstUser(
  users: User[],
) {
  const firstUser = users[0];

  if (!firstUser?.email) {
    return {
      ok: false,
      error: "missing-email",
    };
  }

  return {
    ok: true,
    email: firstUser.email.toLowerCase(),
  };
}

What Changed

  • With strict settings, users[0] and email are treated as possibly missing.
  • The good version handles the empty-list and missing-email cases directly.
  • The return value tells the caller why no email was produced instead of crashing.

Realistic Example

Strict TypeScript settings: Realistic Example

This example uses feature flags where loose optional fields can quietly change product behavior.

Bad TypeScript Example

type Flags = {
  checkout?: {
    newFlow?: boolean;
    rolloutPercent?: number;
  };
};

export function shouldUseNewCheckout(flags: Flags, userBucket: number) {
  return flags.checkout.newFlow && userBucket < flags.checkout.rolloutPercent;
}
type Flags = {
  checkout?: {
    newFlow?: boolean;
    rolloutPercent?: number;
  };
};

export function shouldUseNewCheckout(
  flags: Flags,
  userBucket: number,
) {
  return (
    flags.checkout.newFlow &&
    userBucket <
      flags.checkout.rolloutPercent
  );
}

Good TypeScript Example

type CheckoutFlags =
  | { enabled: false }
  | { enabled: true; rolloutPercent: number };

type Flags = {
  checkout: CheckoutFlags;
};

export function shouldUseNewCheckout(flags: Flags, userBucket: number) {
  if (!flags.checkout.enabled) return false;
  return userBucket < flags.checkout.rolloutPercent;
}
type CheckoutFlags =
  | {
      enabled: false;
    }
  | {
      enabled: true;
      rolloutPercent: number;
    };

type Flags = {
  checkout: CheckoutFlags;
};

export function shouldUseNewCheckout(
  flags: Flags,
  userBucket: number,
) {
  if (!flags.checkout.enabled) return false;

  return (
    userBucket <
    flags.checkout.rolloutPercent
  );
}

What Changed

  • The bad version assumes optional nested fields exist and have matching meaning.
  • The good version makes disabled checkout explicit and requires a rollout percent only when enabled.
  • Strict settings push the code toward a clearer product model instead of scattered ?. checks.

System Example

Strict TypeScript settings: System Example

At system scale, strict settings make uncertainty visible across configuration, stores, and external responses.

Larger System-Level Bad TypeScript Example

type Env = Record<string, string>;

type BillingApi = {
  sync(apiKey: string, webhookSecret: string, retryCount: number): Promise<void>;
};

export function buildBillingClient(env: Env) {
  return {
    apiKey: env.STRIPE_API_KEY,
    webhookSecret: env.STRIPE_WEBHOOK_SECRET,
    retryCount: Number(env.STRIPE_RETRY_COUNT),
  };
}

export async function syncSubscription(env: Env, api: BillingApi) {
  const client = buildBillingClient(env);
  return api.sync(client.apiKey, client.webhookSecret, client.retryCount);
}
type Env = Record<string, string>;

type BillingApi = {
  sync(
    apiKey: string,
    webhookSecret: string,
    retryCount: number,
  ): Promise<void>;
};

export function buildBillingClient(
  env: Env,
) {
  return {
    apiKey: env.STRIPE_API_KEY,
    webhookSecret:
      env.STRIPE_WEBHOOK_SECRET,
    retryCount: Number(
      env.STRIPE_RETRY_COUNT,
    ),
  };
}

export async function syncSubscription(
  env: Env,
  api: BillingApi,
) {
  const client = buildBillingClient(env);

  return api.sync(
    client.apiKey,
    client.webhookSecret,
    client.retryCount,
  );
}

Larger System-Level Good TypeScript Example

type BillingConfig = {
  apiKey: string;
  webhookSecret: string;
  retryCount: number;
};

function readRequired(env: Record<string, string | undefined>, key: string): string {
  const value = env[key];
  if (!value) throw new Error(`Missing ${key}`);
  return value;
}

export function buildBillingConfig(env: Record<string, string | undefined>): BillingConfig {
  const retryCount = Number(env.STRIPE_RETRY_COUNT ?? "3");
  if (!Number.isInteger(retryCount) || retryCount < 0) {
    throw new Error("Invalid STRIPE_RETRY_COUNT");
  }

  return {
    apiKey: readRequired(env, "STRIPE_API_KEY"),
    webhookSecret: readRequired(env, "STRIPE_WEBHOOK_SECRET"),
    retryCount,
  };
}
type BillingConfig = {
  apiKey: string;
  webhookSecret: string;
  retryCount: number;
};

function readRequired(
  env: Record<string, string | undefined>,
  key: string,
): string {
  const value = env[key];

  if (!value)
    throw new Error(`Missing ${key}`);

  return value;
}

export function buildBillingConfig(
  env: Record<string, string | undefined>,
): BillingConfig {
  const retryCount = Number(
    env.STRIPE_RETRY_COUNT ?? "3",
  );

  if (
    !Number.isInteger(retryCount) ||
    retryCount < 0
  ) {
    throw new Error(
      "Invalid STRIPE_RETRY_COUNT",
    );
  }

  return {
    apiKey: readRequired(
      env,
      "STRIPE_API_KEY",
    ),
    webhookSecret: readRequired(
      env,
      "STRIPE_WEBHOOK_SECRET",
    ),
    retryCount,
  };
}

What Changed

  • The bad version pretends every environment variable exists because Record<string, string> hides missing keys.
  • The good version models missing environment values with string | undefined and checks them once.
  • Strict settings make startup configuration failures explicit instead of letting them become later API failures.

When To Use It

Strict TypeScript settings: When To Use It

Use This When

  • New code is being added and can start with strict assumptions.
  • Runtime crashes often come from missing fields, undefined array items, or implicit any.
  • You want review comments to focus on product behavior instead of basic null checks.

Avoid This When

  • A legacy area has too many existing violations to fix safely in one pass.
  • The migration would require broad casts just to satisfy the compiler.
  • Generated code or third-party types need a narrow exception outside product code.

Tradeoffs

Strict settings can slow down the first migration because they reveal old uncertainty. The long-term payoff is clearer code and fewer runtime surprises. Prefer enabling settings for new or touched code before forcing a risky all-at-once cleanup.

  • Parsed inputs
  • Pragmatic migrations
  • Illegal states unrepresentable

Practice Prompt

Strict TypeScript settings: Practice Prompt

Beginner Exercise

Find one place where code assumes an array item, optional field, or environment variable exists. Write the missing case in plain English.

Intermediate Exercise

Refactor that code so TypeScript can see the missing case is handled without using ! or as.

Stretch Exercise

Pick one strict setting, such as noUncheckedIndexedAccess or exactOptionalPropertyTypes, and identify the first small module where enabling it would create useful fixes.

Reflection Question

Did the strict warning reveal a real product state, or was it only noise from a type that needs to be narrowed earlier?

Suggest an edit

Leave a private editorial note. This creates a GitHub issue for this curriculum page.