TypeScript has won. It is the default for new JavaScript projects, adopted by every major framework, and preferred by the overwhelming majority of professional developers. The benefits are clear: type safety catches bugs before they reach production, IDE support provides superior autocompletion and refactoring, and type annotations serve as executable documentation that stays in sync with the code.
But TypeScript is a tool, and like any tool, it can be used well or poorly. A TypeScript codebase full of any types, unnecessary type assertions, and overly complex generics provides little benefit over JavaScript while adding compilation overhead and cognitive load. This guide covers the practices that make TypeScript genuinely useful.
Enable Strict Mode From Day One
Set "strict": true in your tsconfig.json. This enables all strict type checking options: strictNullChecks (null and undefined must be handled explicitly), noImplicitAny (every value must have a type), strictFunctionTypes (function parameter types are checked correctly), and several other checks. Strict mode catches the most bugs and provides the most value from TypeScript's type system.
Enabling strict mode on an existing codebase can be painful — you will discover many implicit assumptions and potential bugs. But the alternative is running TypeScript in permissive mode, which defeats the purpose of using TypeScript at all. For new projects, enable strict mode from the start. For existing projects, enable strict flags one at a time, fixing the resulting errors in focused pull requests.
Avoid any — Use unknown Instead
The any type disables type checking entirely for that value. Every time you use any, you create an untyped hole in your type system where bugs can hide. Use unknown instead — it is the type-safe counterpart to any. A value of type unknown can hold any value, but you must narrow it to a specific type before using it. This forces you to handle the type checking explicitly.
If you find yourself reaching for any, stop and consider why. Is the data truly of unknown shape? Use unknown and narrow with type guards. Is it a complex generic type? Invest the time to type it correctly. Is it a third-party library without types? Write a declaration file or use @ts-expect-error with a comment explaining why.
Use Discriminated Unions for Complex State
Discriminated unions are one of TypeScript's most powerful features for modeling real-world state. Instead of using optional fields and boolean flags, define distinct states as separate types with a discriminant property. This makes impossible states unrepresentable — the type system ensures that your code handles every possible state correctly.
For example, a network request can be in one of four states: idle, loading, success, or error. Instead of a single type with optional data and error fields, define a union of four specific types, each with a status discriminant. TypeScript's control flow analysis then narrows the type in each branch of a switch statement, giving you access to the correct fields without assertions.
Prefer Interfaces for Object Shapes, Types for Everything Else
Use interfaces for defining object shapes — they are extendable, provide clear error messages, and can be augmented across files. Use type aliases for unions, intersections, mapped types, conditional types, and utility types. This convention makes your code consistent and leverages each construct's strengths.
Leverage Utility Types
TypeScript provides built-in utility types that transform existing types. Partial<T> makes all properties optional. Required<T> makes all properties required. Pick<T, K> selects specific properties. Omit<T, K> excludes specific properties. Record<K, V> creates an object type with specified keys and value types. ReturnType<T> extracts the return type of a function.
Use these instead of duplicating type definitions. If your API returns a User object and your update endpoint accepts a partial user, use Partial<User> instead of defining a separate UpdateUser type with the same fields marked as optional.
Type Narrowing and Type Guards
TypeScript narrows types based on control flow — if you check typeof x === 'string', TypeScript knows x is a string inside that block. Custom type guards (functions returning value is Type) let you create reusable narrowing logic for complex types.
Use the in operator to check for properties, instanceof for class instances, and typeof for primitives. For discriminated unions, switch on the discriminant property. TypeScript's exhaustiveness checking in switch statements (using the never type in the default case) ensures you handle every variant of a union.
Generics: Use Them Wisely
Generics make functions and types reusable across different data types while maintaining type safety. But overuse of generics creates code that is harder to read than it is to understand. Use generics when the type relationship is meaningful — a function that maps over an array should preserve the element type. Avoid generics when a simple union or concrete type would suffice.
Constrain generics with extends to limit the types that can be used, providing better error messages and autocompletion. Prefer fewer type parameters — if a generic function has more than two type parameters, consider whether the abstraction is pulling its weight.
Handling External Data (API Responses, User Input)
Data from external sources — API responses, form inputs, URL parameters — is untyped by nature. Do not simply assert the type (response as User) — validate it. Use runtime validation libraries like Zod, io-ts, or valibot that define a schema once and derive both the TypeScript type and the runtime validation from it. This ensures that your type assertions match reality and that invalid data is caught and handled gracefully.
This is especially important for API responses from third-party services — they can change their response format without notice, and your type assertions will silently become wrong. Runtime validation catches these mismatches immediately.
Consistent Code Organization
Organize your types alongside the code that uses them. Component-specific types live in the component file. Shared types live in a dedicated types directory. API response types live near the API client code. Avoid a single monolithic types.ts file that becomes a dumping ground for every type in your application.
ZeonEdge uses TypeScript across all our web applications and internal tools for type-safe, maintainable codebases. Learn more about our development services.
Priya Sharma
Full-Stack Developer and open-source contributor with a passion for performance and developer experience.