TypeScript Code Review Checklist: Type Safety and Modern JavaScript

Published on
Written byChristoffer Artmann
TypeScript Code Review Checklist: Type Safety and Modern JavaScript

The any type bypasses type checking, letting bugs slip through. Runtime errors occur because types claim one shape but data has another. Undefined properties crash production because optional chaining wasn't used. These issues pass the compiler and often escape review because TypeScript's flexibility allows unsafe patterns.

This checklist helps catch what matters: type safety violations, any type abuse, incorrect type assertions, and code that fights TypeScript instead of leveraging its type system effectively.

Quick Reference Checklist

Use this checklist as a quick reference during code review. Each item links to detailed explanations below.

Type Safety

  • any type avoided except when truly necessary (external APIs, migrations)
  • unknown used instead of any for unknown types
  • Type assertions justified and safe: value as Type not value as any as Type
  • No type assertions from any: shows type wasn't understood
  • Function return types explicitly declared
  • Object types use interface or type, not inline types repeated
  • Generic constraints specify requirements: <T extends User> not just <T>
  • Discriminated unions for polymorphic types instead of type assertions

Strict Mode Configuration

  • strict: true enabled in tsconfig.json
  • strictNullChecks catches undefined/null access
  • strictFunctionTypes prevents unsound function assignments
  • noImplicitAny prevents accidental any types
  • noImplicitReturns ensures all code paths return values
  • noUncheckedIndexedAccess treats indexed access as possibly undefined
  • strictPropertyInitialization requires class properties be initialized

Null Safety

  • Optional chaining used for potentially undefined: user?.name
  • Nullish coalescing provides defaults: value ?? defaultValue
  • Type narrowing with if (value !== undefined) guards
  • Non-null assertion ! only when truly guaranteed non-null
  • Undefined checks before accessing properties
  • Function parameters optional when they might not be provided: name?: string
  • Return types reflect possibility of undefined: findUser(): User | undefined

Modern TypeScript Features

  • Utility types used effectively: Partial<T>, Pick<T, K>, Omit<T, K>
  • Const assertions preserve literal types: as const
  • Template literal types for string unions: type Color = `#${string}`
  • Conditional types for type transformations: T extends U ? X : Y
  • Mapped types transform existing types: { [K in keyof T]: T[K][] }
  • Indexed access types extract property types: User['email']
  • satisfies operator validates without widening type: config satisfies Config
  • Type predicates for custom type guards: function isString(x): x is string

React & Frontend Integration

  • Component props use interface with meaningful names
  • Event handlers typed correctly: React.ChangeEvent<HTMLInputElement>
  • Hook dependencies correctly typed
  • Generic components specify type parameters: List<User>
  • Children prop typed: children: React.ReactNode
  • Refs typed appropriately: useRef<HTMLDivElement>(null)
  • Context values fully typed, not any
  • Custom hooks return tuples use as const for correct inference

Code Organization

  • Types exported for public APIs
  • Shared types in separate files, not duplicated
  • Type aliases for complex types improve readability
  • Namespace organization follows module structure
  • Declaration files (.d.ts) for JavaScript libraries
  • Type-only imports: import type { User } from './types'
  • Enums used sparingly (prefer string literal unions)
  • Branded types for nominal typing when needed: type UserId = string & { __brand: 'UserId' }

Type Safety Fundamentals

The any type turns off type checking, defeating TypeScript's purpose. When we see any in code review, we question whether it's truly necessary. Valid uses include gradual migration from JavaScript, integrating with badly-typed third-party libraries, or genuinely dynamic data where the structure can't be known at compile time. These cases should be rare and documented. Most any usage indicates the developer gave up on typing rather than solving a legitimate problem.

unknown provides a safer alternative for unknown types. While any allows any operation, unknown requires type checking before use. Code can assign any value to unknown, but must narrow the type before accessing properties or calling methods. The pattern if (typeof value === 'string') { console.log(value.toUpperCase()) } demonstrates type narrowing that makes unknown safe. This forces developers to think about what types they're actually handling.

Type assertions claim knowledge the type checker can't verify. The syntax value as Type tells TypeScript "trust me, this is actually this type." This should be rare and justified. When we see type assertions, we verify the claim is actually true. Double assertions like value as any as Type indicate the types are incompatible and something is wrong. This pattern almost always hides a real type error that should be fixed properly.

Generic constraints make type parameters useful by specifying requirements. An unconstrained generic <T> means we can't safely do anything with values of type T because we don't know what properties or methods exist. Constraints like <T extends { id: string }> let us safely access the id property. When reviewing generic code, we verify constraints capture actual requirements so the generic code can be type-safe.

Discriminated unions provide type-safe handling of polymorphic data. The pattern uses a common property to distinguish variants:

type Result<T> = { success: true; value: T } | { success: false; error: string }

Checking the discriminant property narrows the type, making other properties safely accessible. This pattern eliminates need for type assertions and makes error handling explicit.

Strict Mode and Compiler Configuration

Strict mode represents TypeScript's best type checking. strict: true in tsconfig.json enables multiple strict checks that catch common bugs. Projects that disable strict mode or individual strict flags usually do so because existing code doesn't pass stricter checking, not because strict checking is wrong. When we see relaxed type checking, we consider whether gradual migration to strict mode would improve code quality.

strictNullChecks treats null and undefined as distinct types rather than assignable to everything. This catches the common bug of accessing properties on potentially null or undefined values. Without this check, code like user.name compiles even when user might be null, causing runtime errors. With strict null checks, we must handle the possibility: user?.name or if (user) { user.name }. This enforcement prevents a huge class of bugs.

noImplicitAny prevents accidentally creating any types by requiring explicit type annotations when TypeScript can't infer a type. Function parameters are common sources of implicit any. The code function process(data) creates an implicit any for data. With noImplicitAny, we must write function process(data: unknown) or provide a more specific type. This makes types explicit and prevents accidental loss of type safety.

strictFunctionTypes enables sound checking of function parameter types, preventing assignments that violate type safety. This catches subtle issues in callback functions where parameter types might not match expectations. Without this check, certain unsafe function assignments compile. The stricter checking prevents these problems at compile time.

noImplicitReturns ensures all code paths in a function return a value when the function declares a return type. Inconsistent returns create bugs where some paths return the expected type and others return undefined implicitly. This check forces complete implementation of return logic, catching missing returns in conditional branches.

Null Safety and Optional Chaining

Optional chaining ?. safely accesses properties that might not exist. The expression user?.profile?.email returns undefined if any step is null or undefined, preventing the TypeError that direct property access would throw. This should be our default for any property access where the object might not exist. When we see code checking for existence before each property access, optional chaining simplifies the logic.

Nullish coalescing ?? provides defaults only for null or undefined, unlike logical OR || which also treats 0, "", and false as falsy. The expression count ?? 0 uses 0 when count is null or undefined but preserves 0 if that's the actual count value. This distinction matters for values where falsy values are valid. When we see || defaultValue in code, we consider whether ?? defaultValue expresses the intent more precisely.

Type narrowing through control flow makes TypeScript understand that we've checked for undefined. The pattern if (user !== undefined) { user.name } works because TypeScript tracks that user is defined within the if block. Similarly, early returns narrow types: if (!user) return; user.name knows user is defined after the guard. These patterns communicate intent while satisfying the type checker.

Non-null assertion ! claims a value isn't null or undefined: user!.name. This bypasses null checking and should be rare. Valid uses include immediately after initialization where TypeScript's flow analysis doesn't understand that initialization guarantees a value. Most non-null assertions indicate we should restructure code to make the guarantee provable or handle the null case properly.

Optional parameters in functions make intent explicit. When a parameter might not be provided, marking it optional function greet(name?: string) documents this in the type signature. The function body can then handle undefined appropriately. This beats using | undefined explicitly and clarifies which parameters callers must provide versus which are optional.

Modern TypeScript Type System

Utility types transform existing types without manually rewriting them. Partial<T> makes all properties optional, useful for update operations where only some fields change. Pick<T, K> extracts specific properties, creating focused types from larger interfaces. Omit<T, K> excludes properties, useful for derived types. Required<T> makes all properties required, inverting Partial. These utilities express intent clearly and update automatically when the base type changes.

Const assertions preserve literal types instead of widening to general types. The expression { x: 10, y: 20 } as const creates type { readonly x: 10; readonly y: 20 } rather than { x: number; y: number }. This enables precise typing of configuration objects, discriminated unions, and other cases where specific values matter. Arrays with as const become readonly tuples with exact types.

Template literal types create unions of strings matching patterns. The type type Color = `#${string}` matches any string starting with #. Combined with literal unions, this creates sophisticated string patterns: type CSSVar = `--${string}`. These types make string manipulation type-safe in ways plain string types can't.

Conditional types enable type-level computation: T extends U ? X : Y chooses types based on conditions. This powers library types like NonNullable<T> which removes null and undefined. When creating reusable type utilities, conditional types provide necessary flexibility. They can feel complex but make formerly impossible generic code type-safe.

Mapped types transform object types: { [K in keyof T]: T[K][] } converts all properties to arrays of their original type. This pattern creates variations of existing types programmatically. Combined with conditional types and utility types, mapped types enable sophisticated type transformations that update automatically when source types change.

Indexed access types extract property types from objects: User['email'] gets the email property's type. This creates types derived from existing types, maintaining consistency when the source type changes. It's particularly useful for accessing nested types without duplicating their definitions.

The satisfies operator validates that a value matches a type without widening its type. The expression config satisfies Config verifies config matches Config but preserves the specific literal types in config. This solves the common problem where type annotations widen types and lose precision.

React and Frontend TypeScript

Component props deserve careful typing. We define props as interfaces with clear names: interface ButtonProps. This makes the component API explicit and enables IntelliSense. Props should be as specific as practical—instead of onClick: Function, use onClick: () => void or more specifically onClick: React.MouseEventHandler<HTMLButtonElement>. Precise types catch mistakes and document intended usage.

Event handlers in React have specific types that vary by element. ChangeEvent<HTMLInputElement> types change events on inputs, MouseEvent<HTMLButtonElement> types mouse events on buttons. Using correct event types enables type-safe access to event properties without assertions. Generic Event types lose specificity and require type assertions to access element-specific properties.

Hooks require careful typing especially for custom hooks. The return type [state, setState] should use as const so TypeScript infers a tuple rather than an array. Dependencies arrays in useEffect and similar hooks should be fully typed—TypeScript can verify all dependencies are listed when types are specific. Missing dependencies cause bugs that proper typing helps prevent.

Generic components let us create reusable components with type-safe data. A List<T> component can render any type of items while maintaining type safety for item properties. The pattern function List<T>({ items, renderItem }: ListProps<T>) makes the component flexible while preventing type mismatches between items and rendering logic.

Context values need full typing to provide type safety throughout the component tree. An untyped context value (or one typed as any) defeats TypeScript's purpose. We define the context value type explicitly and use it consistently: const UserContext = createContext<UserContextValue | undefined>(undefined). This makes context values type-safe everywhere they're consumed.

Bringing It Together

Effective TypeScript code review focuses on type safety as the primary goal. TypeScript's value comes from catching bugs at compile time that JavaScript would only reveal at runtime. Code that bypasses type checking with any or excessive type assertions loses this value. When reviewing TypeScript, we verify the code leverages types to prevent bugs, not just satisfy the compiler.

Not all type issues are equal. A missing return type on a private function might not matter, while any in a public API undermines type safety for all callers. Type assertions in test code might be pragmatic, while type assertions in production code often hide real problems. The key is understanding when type safety matters most and ensuring critical code paths are fully type-safe.

TypeScript's type system continues evolving with each release. Recent versions add features like satisfies, improved type narrowing, and better inference. Teams benefit from staying current with TypeScript versions and adopting new features that improve type safety. Code review creates opportunities to share knowledge about these features and discuss when to use them effectively.