TypeScript Strict Mode in Practice: Catching Bugs with Type Safety

TypeScript Strict Mode in Practice: Catching Bugs with Type Safety

Source: Dev.to

Why I Introduced Strict Mode ## Options I Enabled ## noImplicitAny ## strictNullChecks ## Effect of These Two Options ## Division of Responsibilities: TypeScript and Biome ## Bugs Prevented by Strict Mode ## 1. Missing Null Checks ## 2. Handling Optional Properties ## 3. Type Definition and Schema Mismatch ## IDE Benefits ## Real-time Error Detection ## Accurate Completions ## Safer Refactoring ## Preventing New Errors ## Tips for Gradual Adoption ## 1. Progress Gradually ## 2. Document Reasons for any Types ## 3. Validate External Data with Zod ## Summary This article is part of the and Design Advent Calendar 2025 (Day 18). Yesterday I wrote about "Semantic Search." Today, I'll share my experience introducing TypeScript's strict mode to an existing project. Even with TypeScript, runtime errors still occur. When these errors started piling up, I investigated and found a common pattern. They were happening in places where type checking was too lenient. TypeScript uses lenient settings by default. Without enabling strict mode, many problems slip through undetected. Parameters without type annotations implicitly become any type. Since any allows any operation, type checking becomes ineffective. With noImplicitAny: true, parameters without type annotations cause errors. You can immediately spot "places where you forgot to write types." Without this option, all types implicitly include null and undefined. With strictNullChecks: true, functions that might return null must explicitly declare User | null. Since you get compile errors without null checks, you can prevent missed checks. I delegated unused variable checking (noUnusedLocals) to Biome. TypeScript doesn't recognize _ prefixed variables, which causes issues when you only want to use part of a destructured assignment. Biome is a tool that combines linting (code quality checking) and formatting (code styling). I chose it over ESLint because it's faster and has simpler configuration. I divide checking responsibilities between TypeScript and Biome. Here's an example Biome configuration. By setting noExplicitAny to error, I strictly limit the use of any types. When absolutely necessary, I leave a comment explaining why. Here are typical bug patterns that strict mode can detect. The most common issue is missing null checks. With strictNullChecks enabled, this becomes a compile-time error. This pattern is especially common with relational data. When a user is deleted, the creator field of related data becomes null. Code that doesn't account for this will crash at runtime. Optional properties in API responses are another easy-to-miss pattern. During refactoring, it's easy to forget to update type definitions. With strict mode, everywhere using this type will show errors. You'll see red underlines in your IDE, so you won't miss any needed fixes. When you enable strict mode, you immediately benefit in editors like VS Code. When you write problematic code, red squiggly lines appear in the editor. You can catch issues before running the code, dramatically reducing debugging time. When types are clear, property and method completion suggestions become accurate. When you type user., only properties that actually exist like name and email appear as suggestions. When you change a type definition, errors appear everywhere affected. You don't need to manually search for "what needs to be fixed." If there are any missed fixes, you get compile errors, so you can change code with confidence. Even if you fix existing errors, it's pointless if new code introduces more. Husky is a tool for managing git hooks (scripts that run automatically on commit or push). I set up a pre-commit hook to run type checking before every commit. Commits are blocked if there are TypeScript errors. Since "I'll fix it later" isn't allowed, errors don't accumulate. Here are tips for introducing strict mode to an existing project. Trying to fix a large number of errors at once is discouraging. A more sustainable approach is to fix a few surrounding errors while working on features, or make only new files strict first and gradually convert the rest. When you absolutely need an any type, leave a comment explaining why. This allows for review when type definitions improve later. Zod is a library for runtime data validation. When you define a schema, TypeScript types are automatically generated. For external data like API responses and form inputs, validate with Zod. This lets you manage type definitions and validation in one place. Here are the key points for introducing TypeScript strict mode to an existing project. Ideally, these settings should be enabled from the project's start. Since retrofitting them incurs fixing costs, I recommend enabling strict mode from the beginning for new projects. Tomorrow I'll discuss "Security Measures for Solo Development." Other Articles in This Series Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse CODE_BLOCK: Cannot read property 'name' of null undefined is not a function Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: Cannot read property 'name' of null undefined is not a function CODE_BLOCK: Cannot read property 'name' of null undefined is not a function CODE_BLOCK: { "compilerOptions": { "strict": false, "noImplicitAny": true, "strictNullChecks": true, "noUnusedLocals": false, "noUnusedParameters": false } } Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: { "compilerOptions": { "strict": false, "noImplicitAny": true, "strictNullChecks": true, "noUnusedLocals": false, "noUnusedParameters": false } } CODE_BLOCK: { "compilerOptions": { "strict": false, "noImplicitAny": true, "strictNullChecks": true, "noUnusedLocals": false, "noUnusedParameters": false } } CODE_BLOCK: // With noImplicitAny: false function double(value) { // value is any type return value * 2; } double("hello"); // Compiles fine, but result is NaN Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: // With noImplicitAny: false function double(value) { // value is any type return value * 2; } double("hello"); // Compiles fine, but result is NaN CODE_BLOCK: // With noImplicitAny: false function double(value) { // value is any type return value * 2; } double("hello"); // Compiles fine, but result is NaN CODE_BLOCK: // With strictNullChecks: false const user: User = getUser(); // Might return null console.log(user.name); // Could crash at runtime Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: // With strictNullChecks: false const user: User = getUser(); // Might return null console.log(user.name); // Could crash at runtime CODE_BLOCK: // With strictNullChecks: false const user: User = getUser(); // Might return null console.log(user.name); // Could crash at runtime CODE_BLOCK: { "linter": { "rules": { "correctness": { "noUnusedVariables": "warn", "noUnusedImports": "warn" }, "suspicious": { "noExplicitAny": "error", "noDoubleEquals": "error" } } } } Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: { "linter": { "rules": { "correctness": { "noUnusedVariables": "warn", "noUnusedImports": "warn" }, "suspicious": { "noExplicitAny": "error", "noDoubleEquals": "error" } } } } CODE_BLOCK: { "linter": { "rules": { "correctness": { "noUnusedVariables": "warn", "noUnusedImports": "warn" }, "suspicious": { "noExplicitAny": "error", "noDoubleEquals": "error" } } } } CODE_BLOCK: // Problematic code function getUserName(user: User | null) { return user.name; // user might be null } Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: // Problematic code function getUserName(user: User | null) { return user.name; // user might be null } CODE_BLOCK: // Problematic code function getUserName(user: User | null) { return user.name; // user might be null } CODE_BLOCK: // Fixed function getUserName(user: User | null) { return user?.name ?? 'Anonymous'; } Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: // Fixed function getUserName(user: User | null) { return user?.name ?? 'Anonymous'; } CODE_BLOCK: // Fixed function getUserName(user: User | null) { return user?.name ?? 'Anonymous'; } CODE_BLOCK: interface ApiResponse { data: { items: Item[]; nextCursor?: string; // Optional }; } // Problematic code function getNextPage(response: ApiResponse) { return fetch(`/api?cursor=${response.data.nextCursor}`); // If nextCursor is undefined, this becomes "?cursor=undefined" } Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: interface ApiResponse { data: { items: Item[]; nextCursor?: string; // Optional }; } // Problematic code function getNextPage(response: ApiResponse) { return fetch(`/api?cursor=${response.data.nextCursor}`); // If nextCursor is undefined, this becomes "?cursor=undefined" } CODE_BLOCK: interface ApiResponse { data: { items: Item[]; nextCursor?: string; // Optional }; } // Problematic code function getNextPage(response: ApiResponse) { return fetch(`/api?cursor=${response.data.nextCursor}`); // If nextCursor is undefined, this becomes "?cursor=undefined" } CODE_BLOCK: // Fixed function getNextPage(response: ApiResponse) { if (!response.data.nextCursor) return null; return fetch(`/api?cursor=${response.data.nextCursor}`); } Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: // Fixed function getNextPage(response: ApiResponse) { if (!response.data.nextCursor) return null; return fetch(`/api?cursor=${response.data.nextCursor}`); } CODE_BLOCK: // Fixed function getNextPage(response: ApiResponse) { if (!response.data.nextCursor) return null; return fetch(`/api?cursor=${response.data.nextCursor}`); } CODE_BLOCK: // Changed DB schema // column_name → field_name // Forgot to update type definition interface Column { column_name: string; // Still using old name } Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: // Changed DB schema // column_name → field_name // Forgot to update type definition interface Column { column_name: string; // Still using old name } CODE_BLOCK: // Changed DB schema // column_name → field_name // Forgot to update type definition interface Column { column_name: string; // Still using old name } CODE_BLOCK: // If you remove email from User type interface User { id: string; name: string; // email: string; removed } // Errors appear everywhere user.email is used Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: // If you remove email from User type interface User { id: string; name: string; // email: string; removed } // Errors appear everywhere user.email is used CODE_BLOCK: // If you remove email from User type interface User { id: string; name: string; // email: string; removed } // Errors appear everywhere user.email is used COMMAND_BLOCK: #!/bin/sh # .husky/pre-commit echo "Running type check..." bun run type-check if [ $? -ne 0 ]; then echo "TypeScript errors found. Please fix before committing." exit 1 fi Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: #!/bin/sh # .husky/pre-commit echo "Running type check..." bun run type-check if [ $? -ne 0 ]; then echo "TypeScript errors found. Please fix before committing." exit 1 fi COMMAND_BLOCK: #!/bin/sh # .husky/pre-commit echo "Running type check..." bun run type-check if [ $? -ne 0 ]; then echo "TypeScript errors found. Please fix before committing." exit 1 fi CODE_BLOCK: { "scripts": { "type-check": "tsc --noEmit" } } Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: { "scripts": { "type-check": "tsc --noEmit" } } CODE_BLOCK: { "scripts": { "type-check": "tsc --noEmit" } } CODE_BLOCK: // TODO: Type definitions will improve in library v3 // biome-ignore lint/suspicious/noExplicitAny: temporary workaround const result = someLibrary.parse(data) as any; Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: // TODO: Type definitions will improve in library v3 // biome-ignore lint/suspicious/noExplicitAny: temporary workaround const result = someLibrary.parse(data) as any; CODE_BLOCK: // TODO: Type definitions will improve in library v3 // biome-ignore lint/suspicious/noExplicitAny: temporary workaround const result = someLibrary.parse(data) as any; CODE_BLOCK: import { z } from 'zod'; const UserSchema = z.object({ id: z.string(), name: z.string(), email: z.string().email(), }); type User = z.infer<typeof UserSchema>; // Validate at runtime const user = UserSchema.parse(apiResponse); Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: import { z } from 'zod'; const UserSchema = z.object({ id: z.string(), name: z.string(), email: z.string().email(), }); type User = z.infer<typeof UserSchema>; // Validate at runtime const user = UserSchema.parse(apiResponse); CODE_BLOCK: import { z } from 'zod'; const UserSchema = z.object({ id: z.string(), name: z.string(), email: z.string().email(), }); type User = z.infer<typeof UserSchema>; // Validate at runtime const user = UserSchema.parse(apiResponse); - 12/17: Implementing "Search by Meaning": Introduction to pgvector + OpenAI Embeddings - 12/19: Security Measures for Solo Development: The Minimum You Should Do