Skip to the shelves

Refactoring Boolean Flags: Leveraging TypeScript Discriminated Unions for Enhanced Type Safety

Date due card
Shelf AI & ML
Date in
Readers681

Explore how TypeScript's discriminated unions can replace boolean flags, improving type safety and readability in your codebase.

Refactoring Boolean Flags: Leveraging TypeScript Discriminated Unions for Enhanced Type Safety

Prerequisites: Effective understanding of TypeScript 4.0 or later is necessary, alongside appropriate configurations in your tsconfig.json. This includes setting the "target" to ES2016 or higher and enabling "strict": true, with recursive type aliases supported since TypeScript 3.7.

SitePoint Team

Using boolean flags and optional fields often leads to bugs that are hard to track down in TypeScript applications. Discriminated unions offer a much stronger solution. By rejecting invalid object states at compile time, developers can create more reliable and maintainable code. This article explores the advantages of discriminated unions, providing practical approaches for their implementation, particularly in state machines for multi-step forms.

The Inadequacy of Boolean Flags and Optional Fields

A Common Pitfall: Flawed API Responses

Let’s examine a traditional pattern using a response object structured as { success: boolean; data?: string[] }. Here, data is expected to exist only when success is true. However, TypeScript treats this as an invalid assumption, as it cannot enforce that relationship. This limitation means that the type system sees data as string[] | undefined, inadvertently allowing for constructions such as { success: true } without data, or { success: false, data: ['oops'] }.

interface ApiResponse {
  success: boolean;
  data?: string[];
} 
function processResponse(res: ApiResponse) {
  if (res.success) {
    // TypeScript still considers res.data to be string[] | undefined
    console.log(res.data! .join(', ');
  }
}
processResponse({ success: true }); // This could cause TypeError

The problem here is that the assertion you're forced into hides the actual issue: the type system allows a state that the business logic deems impossible. Consequently, you can trigger runtime errors that are often challenging to debug. This inability to create accurate type relationships not only affects the immediate functionality but can also lead to significant technical debt over time.

Why Control Flow Doesn’t Narrow Optional Fields Effectively

TypeScript’s control flow analysis narrows types based on property checks rather than considering the object’s full structure. For instance, evaluating if (res.success) only narrows res.success but does not adjust the type of res. It remains labeled as an ApiResponse, meaning data continues being string[] | undefined. To efficiently narrow an object's structure, it must first be a union of distinct types. This limitation speaks to the fundamental challenges developers face when creating complex applications, especially as they introduce more states and properties.

Implementing Discriminated Unions

Creating with Literal Discriminants

Discriminated unions unite multiple object types that share a discriminant property, which is identified by a unique string, number, or boolean literal. This design pattern enables effective swift type narrowing during code branch execution. As a result, when you evaluate via a switch statement on the discriminant, TypeScript automatically narrows down the type, making all variant properties available without requiring harsh type assertions. This feature not only reduces friction in your code but significantly enhances its clarity and maintainability.

type ApiResponse =
  | { success: true; data: string[] }
  | { success: false };
};

function processResponse(res: ApiResponse) {
  switch (res.success) {
    case true:
      console.log(res.data.join(', ')); // no assertion needed
      break;
    case false:
      console.log('Request failed');
      break;
  }
}

With this implementation, the compiler entirely rejects any impossible state. For example, if success is true, data must exist; conversely, if success is false, its provisioning will raise a type error.

TypeScript Discriminated Unions: Refactoring Boolean Flags into State Machines

Managing Union Variants from Boolean Runtime Variables

When building union members using a runtime variable typed as boolean, you must be cautious. TypeScript's handling has evolved; while the compiler may accept these constructs in versions after 3.5, ensuring secure type integrity demands that each variant is built within its own branch. Without this, you risk allowing fields that should be unavailable in specific states. This can create situations where your types do not accurately reflect the logic of the application, leading to potential errors in production.

type Result = 
  | { b: true; c: number } 
  | { b: false; };  

function makeResult(flag: boolean, value: number): Result {
  if (flag) {
    return { b: true, c: value };  
  } else {
    return { b: false };  
  }
}

Narrowing Techniques in Practice

Narrowing with Switch Statements

Employing switch with a discriminant property narrows the type to a specific variant defined in the case block, allowing clear visibility of available fields. Although if statements can perform similar narrowing, switch enhances readability and integrates well with exhaustiveness checks by using a default case. This clarity can be especially beneficial in larger codebases where readability and maintainability become paramount over time.

Using the in Operator

The in operator can also narrow types when a unique property exists only within one variant. Assessing if ('data' in obj) will sufficiently narrow down to the variant that includes data. However, it is less explicit than using a discriminant field and may not perform reliably in situations where two variants contain overlapping property names. You might remember that the precision of your type narrowing can directly affect runtime behavior — or veer into an area of silent errors.

type CSVFieldResult = 
  | { kind: 'number'; value: number } 
  | { kind: 'nan'; raw: string };

function parseCSVField(raw: string): CSVFieldResult {
  const parsed = Number(raw);
  if (Number.isNaN(parsed)) {
    return { kind: 'nan', raw };  
  }
  return { kind: 'number', value: parsed };
}

The type of return value must enforce exhaustive handling for reasonable usage and ensure that function callers correctly respond to either outcome. Without such checks, the code could easily become a source of unintended consequences.

Exhaustive Type Checking with the Never Type

Understanding Exhaustiveness

Exhaustiveness requires that all union members are accounted for within conditional logic. Adding new variants without appropriate handling can yield silent errors during runtime, particularly when falling into unexpected default cases. The beauty of exhaustive checks lies in their ability to catch errors early — before they can wreak havoc in production. This is a proactive approach that many developers often overlook.

Implementing the AssertNever Pattern

By integrating an assertNever function as a catch-all in the default branch of a switch, the compiler checks for exhaustiveness. If implemented correctly, all possible variants must be handled, or a compile-time error emerges. This design pattern not only leads to fewer runtime issues, but also encourages more thoughtful design choices, as each new variant demands attention to how it's integrated into existing logic.

SitePoint Team
export function assertNever(x: never): never {
  throw new Error(`Unexpected value: ${JSON.stringify(x)}`);
}

This pattern ensures that any mutations to union types will prompt compile errors across the codebase, assuring type safety. If you're working in this space, embracing such patterns will elevate your coding standards significantly.

Constructing a Multi-Step Form State Machine

Designing the State Union

The FormState type reflects a multi-step process, with the kind discriminant uniquely identifying each step's relevant data. Each variant accommodates only the information pertinent to that step, precluding any impossible states from manifesting. This structure ensures that you're not just building a form, but rather instilling a logical flow that can be followed through the application.

type FormState = 
  | { kind: 'step1'; name: string; email: string } 
  | { kind: 'step2'; step1Data: { name: string; email: string }; address: string; city: string } 
  | { kind: 'submitting'; step1Data: { name: string; email: string }; step2Data: { address: string; city: string } }
  | { kind: 'done'; confirmationId: string }
  | { kind: 'error'; message: string; lastState: Exclude };

By leveraging the error variant, this structure effectively facilitates retry logic while managing state variations efficiently. This is where the concept of preventive coding shines — anticipating failure states helps smooth out the user experience and creates a more robust application.

Defining Actions for State Transitioning

Define transitions between states via discriminated union actions. Each transition needs to be tagged distinctly, and the required data should only traverse from its source step. You must ensure that legitimate actions only allow transitions that adhere to the defined states, eliminating ambiguity and errors during state changes.

type FormAction = 
  | { type: 'NEXT_STEP1'; name: string; email: string }
  | { type: 'NEXT_STEP2'; address: string; city: string }
  | { type: 'SUBMIT' }
  | { type: 'SUBMIT_SUCCESS'; confirmationId: string }
  | { type: 'SUBMIT_FAILURE'; message: string }
  | { type: 'RETRY' }
  | { type: 'RESET' };

Implementing the Exhaustive Reducer

The reducer processes state transitions with nested switch statements. This architecture guarantees exhaustive handling at both levels—state and action—using the assertNever method on unhandled cases. Without this definitive structure, developers risk untracked errors that can derail critical functionality.

function formReducer(state: FormState, action: FormAction): FormState {
  switch (state.kind) {
    case 'step1': 
      switch (action.type) {
        case 'NEXT_STEP1':
          return {
            kind: 'step2',
            step1Data: { name: action.name, email: action.email },
            address: '',
            city: ''
          };
        case 'RESET':
          return INITIAL_STATE;
        default:
          return assertNever(action);
      }
    case 'step2':
      // Further processing...
      }
  }
}

Advantages Over Boolean Flags

Using discriminated unions avoids the pitfalls seen in structures like { step: number; isSubmitting: boolean; error?: string; data?: object }, which allow invalid configurations. Instead, handle state and actions directly, ensuring consistency and type safety throughout. That may seem like a small shift, but it drastically transforms how you interact with your application's state.

Comparison: ?: never versus Discriminated Unions

When contrasting the ?: never approach with discriminated unions:

Criteria ?: never Pattern Discriminated Union
Narrowing Requires type predicates; no direct switching Immediate narrowing via switch
Exhaustiveness Manual checks required Inherent through assertNever
Type Safety May allow unchecked subtype errors Distinct types prevent invalid object shapes
Discriminant necessity No Yes, requires a distinct property
Optimal Use Case Small, two variant objects Three or more variants, complex workflows

The ?: never pattern serves its purpose for lightweight constraints, but falls short in terms of strong type guarantees represented by discriminated unions. There’s a clear path to safer and more comprehensible code when you choose the latter.

Guidelines and Common Pitfalls

When to Choose Discriminated Unions

  • Three or more states with shared characteristics
  • Any form of state machine or workflow
  • API responses with distinct handling paths
  • Reducer implementations requiring exhaustive checking

Common Mistakes to Avoid

  • Using non-literal types for discriminants
  • Building union members from runtime booleans
  • Nesting unions beyond a single layer of indirection

Implications for Future Development

As applications grow in complexity, the reliance on traditional boolean flags and optional types becomes increasingly problematic. Discriminated unions, with their clear structure and compile-time error prevention, serve as a remedy for what often becomes a tangled mess of conditions. The significance of adopting such strategies becomes even clearer when considering team collaborations; they simplify code reviews and onboarding processes for new developers.

In an era where Agile methodologies dominate, fast iterations rely on clear, maintainable code. Transitioning to discriminated unions isn't merely a technical upgrade; it's a philosophical shift towards more robust software development. You'll empower teams to write safer code faster, reducing errors while boosting confidence in changing existing codebases.

Therefore, assess your TypeScript applications critically. Look for those instances where boolean flags and optional properties create uncertainty and refactor. As TypeScript continues to evolve, harnessing the full potential of its types will define success — both in individual projects and the broader development community.

Source: SitePoint Team · www.sitepoint.com

Readers also borrowed