Look for impossible combinations

A screen with loading, failed and loaded booleans can accidentally mark all three as true. The renderer then needs precedence rules that are not visible in the data model. A union can describe the actual alternatives directly. Instead of allowing every boolean combination, define idle, loading, success and error variants whose fields match what each state genuinely knows.

Give each variant a shared discriminant

Use a shared field such as status with a different literal value in each variant. A success variant can contain records, while an error variant contains a message. Checking state.status then narrows the available fields in the branch. This makes it harder to read records from a state that only promises a failure description, without scattering optional access through the component.

Work through a search example

Imagine a project search where success includes a query and a results array. Store those together so the heading and rows describe the same search. An empty results array is still a successful response, while an error means the answer is unknown. Keeping these meanings separate avoids displaying No projects found when the request actually failed to reach the server.

Keep transitions intentional

Write down which events can move the screen between variants: submitting a query, receiving the current result, cancelling, or retrying. A discriminated union describes valid shapes, but it does not automatically enforce the entire transition graph. Keep asynchronous completion tied to the correct operation so a stale result cannot replace a newer loading or success state simply because its type is valid.

Use exhaustive handling where it helps

A switch over the discriminant can make every variant visible during review. An exhaustive never check can surface a missing branch when a new variant is added. Avoid forcing every property into a shared optional bag merely to simplify one call site. That approach weakens the contract and recreates the uncertainty the union was meant to remove.

Verify both typing and behavior

Create an example of every variant and confirm the renderer handles each one. Intentionally try reading success data in the error branch and let the compiler reject it. Then test real interactions, because TypeScript cannot prove that a request result belongs to the current query. Static checking and lifecycle tests answer different questions and work best when both reflect the same state model.

Further reading

Put the idea to work.

Explore free browser tools for images, text and developer workflows.

Try a free tool