Skip to main content

Type Guards

import { isInit, isPending, isSuccess, isError } from '@intrig/react/network-state';

const [users] = useGetUsers({ fetchOnMount: true });

if (isInit(users)) return <LoadButton />;
if (isPending(users)) return <Spinner />;
if (isError(users)) return <ErrorView error={users.error} />;
if (isSuccess(users)) return <UserList data={users.data} />;

Type guards safely narrow NetworkState to specific variants, enabling type-safe access to state properties.

Available Guards

FunctionNarrows ToUse Case
isInit(state)InitState<T>Check if request hasn't started
isPending(state)PendingState<T>Show loading UI, access progress
isSuccess(state)SuccessState<T>Access data safely
isError(state)ErrorState<T>Handle errors with error

isInit

Check if request hasn't started yet:

function ProductView() {
const [product] = useGetProduct();

if (isInit(product)) {
// TypeScript knows: { state: 'init' }
return (
<div>
<button onClick={() => fetchProduct({ id: productId })}>
Load Product
</button>
</div>
);
}

// Handle other states...
}

State shape:

{ state: 'init' }

When it's true:

  • Component just mounted (without fetchOnMount)
  • State was cleared with clear()
  • State reset by clearOnUnmount

isPending

Check if request is in progress:

function UserList() {
const [users] = useGetUsers({ fetchOnMount: true });

if (isPending(users)) {
// TypeScript knows: { state: 'pending', progress?: Progress, data?: T }

// Show progress for uploads/downloads
if (users.progress) {
const percentage = users.progress.total
? Math.round((users.progress.loaded / users.progress.total) * 100)
: 0;

return <ProgressBar value={percentage} />;
}

return <LoadingSpinner />;
}

// Handle other states...
}

State shape:

{
state: 'pending',
progress?: {
type?: 'upload' | 'download',
loaded: number,
total?: number
},
data?: T // For SSE streams
}

When it's true:

  • Request is in flight
  • Upload/download in progress
  • SSE stream is active

Progress tracking:

if (isPending(uploadState) && uploadState.progress) {
const { loaded, total, type } = uploadState.progress;

return (
<div>
<span>{type === 'upload' ? 'Uploading' : 'Downloading'}</span>
<progress value={loaded} max={total} />
<span>{loaded} / {total} bytes</span>
</div>
);
}

isSuccess

Check if request completed successfully:

function ProductDetails() {
const [product] = useGetProduct({ fetchOnMount: true });

if (isSuccess(product)) {
// TypeScript knows: { state: 'success', data: T, headers?: Record<string, any> }

// Safe access to data
return (
<div>
<h1>{product.data.name}</h1>
<p>{product.data.description}</p>
<Price amount={product.data.price} />

{/* Access response headers if needed */}
{product.headers?.['x-rate-limit-remaining'] && (
<div>API calls remaining: {product.headers['x-rate-limit-remaining']}</div>
)}
</div>
);
}

// Handle other states...
}

State shape:

{
state: 'success',
data: T,
headers?: Record<string, any | undefined>
}

When it's true:

  • Request completed without errors
  • Response validated against OpenAPI schema (if enabled)
  • Data is ready for use

Accessing headers:

if (isSuccess(users)) {
const nextPageToken = users.headers?.['x-next-page'];
const rateLimit = users.headers?.['x-rate-limit-remaining'];
const etag = users.headers?.['etag'];

return (
<>
<UserList data={users.data} />
{nextPageToken && <LoadMoreButton token={nextPageToken} />}
</>
);
}

isError

Check if request failed:

function OrderForm() {
const [orderResult, createOrder] = useCreateOrder();

if (isError(orderResult)) {
// TypeScript knows: { state: 'error', error: E, statusCode?: number, request?: any }

// Handle different error types
if (orderResult.statusCode === 400) {
return <ValidationError error={orderResult.error} />;
}

if (orderResult.statusCode === 401) {
return <LoginPrompt />;
}

if (orderResult.statusCode === 403) {
return <PermissionDenied />;
}

if (orderResult.statusCode && orderResult.statusCode >= 500) {
return <ServerError error={orderResult.error} />;
}

// Network or unknown error
return (
<div>
<ErrorMessage error={orderResult.error} />
<button onClick={() => createOrder(orderData)}>Retry</button>
</div>
);
}

// Handle other states...
}

State shape:

{
state: 'error',
error: E,
statusCode?: number,
request?: any
}

When it's true:

  • Network error occurred
  • Server returned error status (4xx, 5xx)
  • Response failed OpenAPI schema validation
  • Request failed validation

Error types:

  • Network errors - No statusCode, connection failed
  • HTTP errors - Has statusCode, server returned error
  • Validation errors - Response didn't match OpenAPI schema

Exhaustive Pattern Matching

Type guards enable exhaustive checking:

function renderState<T>(state: NetworkState<T>) {
if (isInit(state)) {
return <InitView />;
}

if (isPending(state)) {
return <LoadingView />;
}

if (isSuccess(state)) {
return <DataView data={state.data} />;
}

if (isError(state)) {
return <ErrorView error={state.error} />;
}

// TypeScript enforces all states are handled
const _exhaustive: never = state;
return null;
}

TypeScript will error if you miss a state or if a new state is added.

Complete Example

function UserProfile({ userId }: { userId: string }) {
const [user, fetchUser, clearUser] = useGetUser({
fetchOnMount: true,
clearOnUnmount: true,
params: { id: userId }
});

// Initial state - show load button
if (isInit(user)) {
return (
<div>
<p>User profile not loaded</p>
<button onClick={() => fetchUser({ id: userId })}>Load Profile</button>
</div>
);
}

// Loading state - show spinner
if (isPending(user)) {
return (
<div>
<Spinner />
<p>Loading user profile...</p>
</div>
);
}

// Error state - show error with retry
if (isError(user)) {
return (
<div>
<h2>Error Loading Profile</h2>

{user.statusCode === 404 && <p>User not found</p>}
{user.statusCode === 403 && <p>You don't have permission to view this profile</p>}
{!user.statusCode && <p>Network error - check your connection</p>}
{user.statusCode && user.statusCode >= 500 && <p>Server error - try again later</p>}

<button onClick={() => fetchUser({ id: userId })}>Retry</button>
<button onClick={clearUser}>Clear</button>
</div>
);
}

// Success state - show user data
if (isSuccess(user)) {
return (
<div>
<img src={user.data.avatar} alt={user.data.name} />
<h1>{user.data.name}</h1>
<p>{user.data.email}</p>
<p>Member since: {new Date(user.data.createdAt).toLocaleDateString()}</p>

<button onClick={() => fetchUser({ id: userId })}>Refresh</button>
</div>
);
}

// Exhaustiveness check
return null;
}

Why Type Guards

Without type guards (unsafe):

// ✗ Bad - TypeScript can't guarantee data exists
const name = users.data.name; // Error: data might not exist

// ✗ Bad - Optional chaining everywhere
const name = users.data?.name;
const email = users.data?.email;

With type guards (safe):

// ✓ Good - TypeScript knows data exists in this branch
if (isSuccess(users)) {
const name = users.data.name; // No optional chaining needed
const email = users.data.email; // TypeScript knows these exist
}

Type Signatures

function isInit<T, E = unknown>(
state: NetworkState<T, E>
): state is InitState<T, E>

function isPending<T, E = unknown>(
state: NetworkState<T, E>
): state is PendingState<T, E>

function isSuccess<T, E = unknown>(
state: NetworkState<T, E>
): state is SuccessState<T, E>

function isError<T, E = unknown>(
state: NetworkState<T, E>
): state is ErrorState<T, E>