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]andemailare 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 | undefinedand 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.
Related Concepts
- 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.