I spent an entire afternoon last month debugging a TypeScript project that compiled without a single error — and crashed immediately at runtime. The issue? A JSON API response that returned null where my types promised a string. TypeScript had no way of knowing because I’d used as to force the type, and the compiler trusted me blindly.
TypeScript’s type system is powerful, but it only works if you work with it instead of around it. Here are the patterns and pitfalls I’ve collected from years of writing TypeScript in production.
Type Narrowing: The Most Useful Skill Nobody Teaches
Type narrowing is how you take a broad type and make it specific within a code block. This is the single most important TypeScript concept for writing safe code.
typeof Guards
function processValue(input: string | number) {
if (typeof input === 'string') {
// TypeScript knows input is string here
return input.toUpperCase();
}
// TypeScript knows input is number here
return input.toFixed(2);
}
in Operator for Object Types
interface Dog { bark(): void; breed: string; }
interface Cat { meow(): void; color: string; }
function handlePet(pet: Dog | Cat) {
if ('bark' in pet) {
pet.bark(); // TypeScript knows this is Dog
} else {
pet.meow(); // TypeScript knows this is Cat
}
}
Discriminated Unions (The Pattern I Use Everywhere)
This is my go-to pattern for handling different response shapes:
type ApiResponse =
| { status: 'success'; data: User; }
| { status: 'error'; message: string; code: number; }
| { status: 'loading'; };
function handleResponse(response: ApiResponse) {
switch (response.status) {
case 'success':
console.log(response.data.name); // ✅ TypeScript knows data exists
break;
case 'error':
console.error(response.message); // ✅ TypeScript knows message exists
break;
case 'loading':
showSpinner();
break;
}
}
The status field acts as a discriminant — TypeScript uses it to narrow the type automatically. I use this pattern for form states, API calls, WebSocket messages, and basically anything with multiple possible shapes.
The as Keyword: TypeScript’s Escape Hatch (and Trap)
Type assertions with as are the most common source of runtime errors in TypeScript codebases. They tell the compiler “trust me, I know better” — and the compiler does, without any runtime check.
// Dangerous: no runtime validation
const user = JSON.parse(apiResponse) as User;
// If apiResponse doesn't match User shape, this silently breaks
// Even more dangerous: double assertion
const value = someUnknown as unknown as SpecificType;
// This bypasses ALL type checking
When as Is Acceptable
There are exactly three situations where I think as is justified:
- DOM element selection — when you know the element type exists:
const canvas = document.getElementById('game') as HTMLCanvasElement;
- Test mocking — creating partial objects for unit tests:
const mockUser = { id: 1, name: 'Test' } as User;
- Const assertions — narrowing literal values:
const config = { env: 'production', debug: false } as const;
Everything else deserves runtime validation.
Runtime Validation: Bridging the Compile-Time Gap
TypeScript types disappear at runtime. When data crosses a trust boundary — API responses, user input, file reads, URL parameters — you need runtime validation.
Manual Type Guards
interface User {
id: number;
name: string;
email: string;
}
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value &&
'email' in value &&
typeof (value as User).id === 'number' &&
typeof (value as User).name === 'string' &&
typeof (value as User).email === 'string'
);
}
// Safe usage
const data = JSON.parse(response);
if (isUser(data)) {
console.log(data.name); // TypeScript knows this is User
} else {
throw new Error('Invalid user data');
}
Validation Libraries
For production code, manual type guards become unwieldy. Libraries like Zod combine validation and type inference:
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
name: z.string().min(1),
email: z.string().email(),
role: z.enum(['admin', 'user', 'guest']),
});
type User = z.infer<typeof UserSchema>; // Type generated from schema
const result = UserSchema.safeParse(apiResponse);
if (result.success) {
console.log(result.data.name); // Fully typed and validated
} else {
console.error(result.error.issues); // Structured validation errors
}
This eliminates the gap between compile-time types and runtime data entirely. I now use Zod for every external data boundary in my projects.
Working with JSON? Our JSON Formatter helps you inspect and validate JSON structure before writing your TypeScript types. And when converting between config formats, the YAML to JSON Converter handles the format translation.
Generics: Powerful but Easy to Overcomplicate
Generics let you write reusable, type-safe functions and classes. The mistake I see most often is developers making everything generic when it doesn’t need to be.
Good Use of Generics
// A generic function that preserves type information
function getFirst<T>(items: T[]): T | undefined {
return items[0];
}
const num = getFirst([1, 2, 3]); // type: number | undefined
const str = getFirst(['a', 'b']); // type: string | undefined
Constrained Generics
When your generic type needs specific properties:
interface HasId {
id: string | number;
}
function findById<T extends HasId>(items: T[], id: T['id']): T | undefined {
return items.find(item => item.id === id);
}
When NOT to Use Generics
// Unnecessary: just use the concrete type
function badGeneric<T extends string>(name: T): T {
return name;
}
// This generic adds complexity without flexibility
// Better: just use string
function getName(name: string): string {
return name;
}
My rule of thumb: If a generic type parameter is used in only one position, you probably don’t need it. Generics shine when they connect the types of multiple parameters or a parameter to a return type.
Utility Types You Should Know
TypeScript ships with built-in utility types that solve everyday problems:
interface User {
id: number;
name: string;
email: string;
createdAt: Date;
}
// Make all properties optional (for patch/update endpoints)
type UpdateUserDTO = Partial<User>;
// Make specific properties required
type CreateUserDTO = Required<Pick<User, 'name' | 'email'>>;
// Exclude specific properties
type PublicUser = Omit<User, 'email'>;
// Make all properties readonly (for immutable state)
type FrozenUser = Readonly<User>;
// Record type for dictionaries
type UserCache = Record<string, User>;
Combining Utility Types
Real-world DTOs often need a combination:
// An update DTO that allows partial changes but never touches id or createdAt
type UpdateUser = Partial<Omit<User, 'id' | 'createdAt'>>;
// A creation DTO that requires name and email but makes everything else optional
type CreateUser = Required<Pick<User, 'name' | 'email'>> & Partial<Omit<User, 'name' | 'email' | 'id'>>;
Common Mistakes I Keep Seeing
Mistake 1: Using any Instead of unknown
// Bad: any disables ALL type checking downstream
function parseConfig(raw: any) {
return raw.database.host; // No errors, but might crash
}
// Good: unknown forces you to validate before use
function parseConfig(raw: unknown) {
if (typeof raw === 'object' && raw !== null && 'database' in raw) {
// Now you need to validate further — TypeScript enforces safety
}
}
Every any in your codebase is a potential runtime crash. I treat any like a code smell — it’s sometimes necessary (third-party libraries with bad types), but it should always have a comment explaining why.
Mistake 2: Ignoring strictNullChecks
// With strictNullChecks disabled (bad):
const user = users.find(u => u.id === 1);
console.log(user.name); // Might crash at runtime — find can return undefined
// With strictNullChecks enabled (good):
const user = users.find(u => u.id === 1);
if (user) {
console.log(user.name); // Safe — TypeScript forces the check
}
Always enable strict: true in tsconfig.json. It catches entire categories of bugs that would otherwise only surface in production.
Mistake 3: Enums When Union Types Are Simpler
// Verbose enum
enum Status {
Active = 'active',
Inactive = 'inactive',
Pending = 'pending',
}
// Simpler union type — same type safety, less code
type Status = 'active' | 'inactive' | 'pending';
String literal unions are zero-runtime-cost (they disappear after compilation), autocomplete just as well, and don’t produce the surprising JavaScript output that enums do. I only use enums when I need runtime access to the values (e.g., iterating over all statuses).
tsconfig.json Settings That Matter
The compiler options that have the biggest impact on type safety:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true
}
}
noUncheckedIndexedAccess is the one most people miss — it makes array access and object index access return T | undefined instead of T, catching a huge category of out-of-bounds bugs.
Quick Reference
| Pattern | When to Use |
|---|---|
| Type narrowing (typeof, in) | Handling union types safely |
| Discriminated unions | API responses, events, state machines |
| Type guards (is) | Validating external data |
| Zod / io-ts | Production API boundaries |
| Generics | Reusable utilities connecting multiple types |
| Utility types (Partial, Omit) | DTOs, API contracts |
unknown over any | Any external or untyped data |
Further Reading
Working with typed data? Use our JSON Formatter to inspect API responses, the Diff Checker to compare type definitions, and the Regex Tester to validate string patterns in your schemas.