Explore how TypeScript's discriminated unions can replace boolean flags, improving type safety and readability in your codebase.
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.
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'] }.
interfaceApiResponse{ success:boolean; data?:string[];} functionprocessResponse(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.
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.
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.
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.
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.
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.
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.