Frontend JavaScript/TypeScript Patterns¶
This document describes the modern JavaScript and TypeScript patterns used in this codebase. These patterns promote type safety, explicit error handling, and clean functional composition.
Pattern Overview¶
flowchart TB
subgraph ErrorHandling["Error Handling Layer"]
direction LR
Result["Result Type<br/>Ok | Err"]
Match["Pattern Matching<br/>match()"]
Async["Async Conversion<br/>fromPromise()"]
end
subgraph RequestManagement["Request Management"]
direction LR
Abort["AbortController<br/>Cancellation"]
Timeout["Timeout Handling<br/>fetchWithTimeout()"]
Signal["Signal Combining<br/>anySignal()"]
end
subgraph DataFetching["Data Fetching Patterns"]
direction LR
Polling["usePolling Hook<br/>Interval refresh"]
AllSettled["Promise.allSettled<br/>Partial failures"]
Events["Typed EventEmitter<br/>WebSocket messages"]
end
subgraph Composition["Functional Composition"]
direction LR
Pipe["pipe()<br/>Left to right"]
Curry["curry()<br/>Partial application"]
Utils["debounce / throttle<br/>Rate limiting"]
end
ErrorHandling --> RequestManagement
RequestManagement --> DataFetching
DataFetching --> Composition
style ErrorHandling fill:#1e3a5f,stroke:#76B900,color:#fff
style RequestManagement fill:#2d4a3e,stroke:#76B900,color:#fff
style DataFetching fill:#4a3a2d,stroke:#76B900,color:#fff
style Composition fill:#1e3a5f,stroke:#76B900,color:#fff Frontend patterns are organized into four layers: error handling, request management, data fetching, and functional composition.
Table of Contents¶
- Result Type for Error Handling
- AbortController for Request Cancellation
- usePolling Hook
- Typed Event Emitter
- Promise.allSettled Patterns
- Functional Utilities
Result Type for Error Handling¶
Location: frontend/src/lib/result.ts
The Result type is a discriminated union that represents either success (Ok) or failure (Err). This pattern, inspired by Rust, makes error handling explicit in the type system rather than relying on exceptions.
Why Use Result?¶
- Explicit error handling: Errors are visible in function signatures
- Type safety: TypeScript enforces handling both success and error cases
- Composable: Chain operations with
map,flatMap, and other utilities - No surprises: Unlike exceptions, errors don't propagate silently
Basic Usage¶
import { ok, err, isOk, isErr, Result } from '@/lib/result';
// Function that returns a Result
function divide(a: number, b: number): Result<number, string> {
if (b === 0) {
return err('Division by zero');
}
return ok(a / b);
}
// Using the Result
const result = divide(10, 2);
if (isOk(result)) {
console.log(result.value); // 5
} else {
console.error(result.error); // Won't reach here
}
Transformation Utilities¶
import { map, mapErr, flatMap, unwrapOr } from '@/lib/result';
// Transform success values
const doubled = map(result, (n) => n * 2);
// Transform error values
const mapped = mapErr(result, (e) => new Error(e));
// Chain Result-returning functions
const validated = flatMap(result, (n) => (n > 0 ? ok(n) : err('Must be positive')));
// Extract with default
const value = unwrapOr(result, 0);
Pattern Matching¶
import { match } from '@/lib/result';
const message = match(result, {
onOk: (value) => `Success: ${value}`,
onErr: (error) => `Error: ${error}`,
});
Async Operations¶
import { fromPromise, toPromise } from '@/lib/result';
// Convert Promise to Result
const result = await fromPromise(fetch('/api/data'));
if (isOk(result)) {
const response = result.value;
// Handle response
}
// Convert Result back to Promise
await toPromise(ok(42)); // Resolves to 42
await toPromise(err('oops')); // Rejects with 'oops'
Combining Results¶
import { all, allTuple } from '@/lib/result';
// Combine array of Results
const results = [ok(1), ok(2), ok(3)];
const combined = all(results); // Ok([1, 2, 3])
// With type-safe tuples
const tuple = allTuple([ok(42), ok('hello'), ok(true)] as const);
// Result<[number, string, boolean], never>
AbortController for Request Cancellation¶
Location: frontend/src/services/api.ts
AbortController is the standard Web API for cancelling fetch requests. This is essential for preventing race conditions when user input changes rapidly (e.g., search filters) and for cleanup in React effects.
Why Use AbortController?¶
- Prevent race conditions: Cancel stale requests when new ones are made
- Clean React effects: Cancel pending requests on unmount
- Resource efficiency: Don't process responses that are no longer needed
Basic Usage¶
// Create controller and pass signal to fetch
const controller = new AbortController();
fetch('/api/data', { signal: controller.signal })
.then((response) => response.json())
.then((data) => console.log(data))
.catch((error) => {
if (error.name === 'AbortError') {
console.log('Request was cancelled');
} else {
console.error('Request failed:', error);
}
});
// Cancel the request
controller.abort();
React Effect Pattern¶
useEffect(() => {
const controller = new AbortController();
async function fetchData() {
try {
const response = await fetch('/api/data', {
signal: controller.signal,
});
const data = await response.json();
setData(data);
} catch (error) {
if (!isAbortError(error)) {
setError(error);
}
}
}
fetchData();
// Cleanup: cancel request on unmount or dependency change
return () => controller.abort();
}, [dependency]);
Checking for Abort Errors¶
import { isAbortError } from '@/services/api';
try {
await fetchData({ signal: controller.signal });
} catch (error) {
if (isAbortError(error)) {
// Request was cancelled - this is expected, not an error
return;
}
// Handle actual errors
console.error('Request failed:', error);
}
Timeout with AbortController¶
import { fetchWithTimeout, TimeoutError, isTimeoutError } from '@/services/api';
try {
// Automatically aborts after 5 seconds
const response = await fetchWithTimeout('/api/data', { timeout: 5000 });
} catch (error) {
if (isTimeoutError(error)) {
console.error('Request timed out');
}
}
Combining Signals¶
import { anySignal } from '@/services/api';
// Combine user cancellation with timeout
const userController = new AbortController();
const timeoutController = new AbortController();
// Aborts if either signal aborts
const combined = anySignal([userController.signal, timeoutController.signal]);
fetch('/api/data', { signal: combined });
usePolling Hook¶
Location: frontend/src/hooks/usePolling.ts
A generic React hook for polling data at regular intervals. Handles loading state, errors, and provides a manual refetch function.
Why Use usePolling?¶
- Reusable: Same pattern for all polling needs
- Clean API: Handles loading/error state automatically
- Callbacks: Optional success/error handlers
- Controllable: Enable/disable polling dynamically
Basic Usage¶
import { usePolling } from '@/hooks/usePolling';
function SystemStatus() {
const { data, loading, error, refetch } = usePolling({
fetcher: () => fetchSystemHealth(),
interval: 30000, // Poll every 30 seconds
});
if (loading) return <Spinner />;
if (error) return <Error message={error.message} />;
return (
<div>
<StatusBadge status={data.status} />
<button onClick={refetch}>Refresh</button>
</div>
);
}
With Callbacks¶
const { data } = usePolling({
fetcher: () => fetchAlerts(),
interval: 10000,
enabled: isConnected,
onSuccess: (data) => {
if (data.hasNew) {
showNotification('New alerts!');
}
},
onError: (error) => {
reportError('Alert polling failed', error);
},
});
Conditional Polling¶
const { data } = usePolling({
fetcher: () => fetchQueueStatus(),
interval: 5000,
enabled: queueIsActive, // Only poll when queue is active
});
Typed Event Emitter¶
Location: frontend/src/hooks/typedEventEmitter.ts
A type-safe event emitter for WebSocket message handling. Provides compile-time type checking for event names and their payloads.
Why Use Typed Event Emitter?¶
- Type safety: Event names and payloads are type-checked
- Autocomplete: IDE knows available events and their types
- Prevents bugs: Can't emit wrong data types for an event
Basic Usage¶
import { TypedWebSocketEmitter } from '@/hooks/typedEventEmitter';
const emitter = new TypedWebSocketEmitter();
// Subscribe with type-safe handler
const unsubscribe = emitter.on('event', (data) => {
// TypeScript knows data is SecurityEventData
console.log(data.risk_score);
});
// Emit with type-checked payload
emitter.emit('event', {
id: '123',
camera_id: 'front_door',
risk_score: 75,
risk_level: 'high',
summary: 'Person detected',
});
// Cleanup
unsubscribe();
One-Time Listeners¶
// Handler automatically removed after first call
emitter.once('system_status', (data) => {
console.log('Initial status:', data.health);
});
Handling WebSocket Messages¶
// In WebSocket onmessage handler
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
const handled = emitter.handleMessage(message);
if (!handled) {
console.warn('Unknown message type:', message);
}
};
Utility Methods¶
// Check if event has listeners
if (emitter.has('event')) {
emitter.emit('event', data);
}
// Get listener count
const count = emitter.listenerCount('event');
// Remove all listeners for an event
emitter.removeAllListeners('event');
// Clear all listeners
emitter.clear();
// Get all events with listeners
const events = emitter.events();
Promise.allSettled Patterns¶
Promise.allSettled waits for all promises to complete (either fulfilled or rejected) and returns their results. This is essential when you need data from multiple sources and want to handle partial failures gracefully.
Why Use Promise.allSettled?¶
- No short-circuit: Unlike
Promise.all, doesn't fail on first error - Partial data: Get successful results even if some requests fail
- Graceful degradation: Show what you can, handle what you can't
Basic Usage¶
const [userResult, settingsResult, notificationsResult] = await Promise.allSettled([
fetchUser(userId),
fetchSettings(userId),
fetchNotifications(userId),
]);
// Each result is either { status: 'fulfilled', value: T }
// or { status: 'rejected', reason: Error }
const user = userResult.status === 'fulfilled' ? userResult.value : null;
const settings = settingsResult.status === 'fulfilled' ? settingsResult.value : defaults;
const notifications = notificationsResult.status === 'fulfilled' ? notificationsResult.value : [];
Real-World Example: AI Metrics Hook¶
// From useAIMetrics.ts
const [
metricsResult,
telemetryResult,
healthResult,
pipelineResult,
dlqResult,
detectionStatsResult,
] = await Promise.allSettled([
fetchAIMetrics(),
fetchTelemetry(),
fetchHealth(),
fetchPipelineLatency(60),
fetchDlqStats(),
fetchDetectionStats(),
]);
// Extract successful results with fallbacks
const metrics = metricsResult.status === 'fulfilled' ? metricsResult.value : null;
const telemetry = telemetryResult.status === 'fulfilled' ? telemetryResult.value : null;
const health = healthResult.status === 'fulfilled' ? healthResult.value : null;
// Update state with whatever data we got
setState({
metrics,
telemetry,
health,
lastUpdated: new Date().toISOString(),
});
Helper Function¶
function getSettledValue<T>(result: PromiseSettledResult<T>, fallback: T): T {
return result.status === 'fulfilled' ? result.value : fallback;
}
// Usage
const user = getSettledValue(userResult, defaultUser);
Functional Utilities¶
Location: frontend/src/lib/functional.ts
A collection of utility functions for functional programming patterns.
Function Composition¶
pipe (left-to-right)¶
import { pipe } from '@/lib/functional';
const processData = pipe(
(x: number) => x * 2,
(x: number) => x + 1,
(x: number) => x.toString()
);
processData(5); // "11"
compose (right-to-left)¶
import { compose } from '@/lib/functional';
// Mathematical notation: f . g = f(g(x))
const process = compose(
(x: number) => x.toString(), // Applied last
(x: number) => x + 1,
(x: number) => x * 2 // Applied first
);
process(5); // "11"
Currying¶
import { curry, curry3 } from '@/lib/functional';
// Two-argument curry
const add = curry((a: number, b: number) => a + b);
const add5 = add(5);
add5(3); // 8
// Useful with array methods
const numbers = [1, 2, 3];
const multiply = curry((factor: number, x: number) => x * factor);
numbers.map(multiply(2)); // [2, 4, 6]
// Three-argument curry
const replace = curry3((search: string, replacement: string, str: string) =>
str.replace(search, replacement)
);
const sanitize = replace('<', '<');
sanitize('<script>'); // '<script>'
Debounce¶
Delays function execution until after wait time has elapsed since last call.
import { debounce } from '@/lib/functional';
const debouncedSearch = debounce((query: string) => {
console.log('Searching:', query);
}, 300);
// Only logs once, 300ms after last call
debouncedSearch('a');
debouncedSearch('ab');
debouncedSearch('abc'); // Only this triggers
// Control methods
debouncedSearch.cancel(); // Cancel pending call
debouncedSearch.flush(); // Execute immediately
debouncedSearch.pending(); // Check if call is pending
// Options
const leading = debounce(fn, 300, { leading: true }); // Fire on first call
const noTrailing = debounce(fn, 300, { trailing: false }); // Don't fire after wait
Throttle¶
Limits function calls to at most once per wait period.
import { throttle } from '@/lib/functional';
const throttledScroll = throttle(() => {
console.log('Scroll position:', window.scrollY);
}, 100);
window.addEventListener('scroll', throttledScroll);
// Options
const noLeading = throttle(fn, 100, { leading: false }); // Delay first call
const noTrailing = throttle(fn, 100, { trailing: false }); // No call after period
Other Utilities¶
import { once, memoize, negate, constant, identity } from '@/lib/functional';
// once - Call at most once
const initialize = once(() => {
console.log('Initialized');
return config;
});
initialize(); // Logs "Initialized"
initialize(); // Returns cached config, no log
// memoize - Cache results
const expensive = memoize((n: number) => {
console.log('Computing...');
return n * 2;
});
expensive(5); // Logs "Computing...", returns 10
expensive(5); // Returns 10 (cached, no log)
// negate - Flip boolean result
const isEven = (n: number) => n % 2 === 0;
const isOdd = negate(isEven);
[1, 2, 3, 4].filter(isOdd); // [1, 3]
// constant - Always return same value
const always42 = constant(42);
always42(); // 42
// identity - Return input unchanged
identity(42); // 42
Data Flow Visualization¶
flowchart LR
subgraph UserAction["User Action"]
Click["Click / Input"]
end
subgraph FetchLayer["Fetch Layer"]
direction TB
Controller["AbortController"]
Fetch["fetch() with signal"]
Timeout["Timeout check"]
end
subgraph ResultLayer["Result Handling"]
direction TB
FromPromise["fromPromise()"]
ResultType["Result<T, E>"]
Match2["match() or isOk()"]
end
subgraph StateLayer["State Update"]
direction TB
SetState["setState()"]
Render["Re-render"]
end
Click --> Controller
Controller --> Fetch
Fetch --> Timeout
Timeout --> FromPromise
FromPromise --> ResultType
ResultType --> Match2
Match2 -->|"Success"| SetState
Match2 -->|"Error"| ErrorUI["Error UI"]
SetState --> Render
style UserAction fill:#1e3a5f,stroke:#76B900,color:#fff
style FetchLayer fill:#2d4a3e,stroke:#76B900,color:#fff
style ResultLayer fill:#4a3a2d,stroke:#76B900,color:#fff
style StateLayer fill:#1e3a5f,stroke:#76B900,color:#fff Data flows from user action through fetch with cancellation, Result type handling, and finally state update.
Best Practices¶
1. Prefer Result over Exceptions¶
// Avoid
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error('Not found');
return response.json();
}
// Prefer
async function fetchUser(id: string): Promise<Result<User, ApiError>> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
return err({ status: response.status, message: 'Not found' });
}
return ok(await response.json());
}
2. Always Cancel Requests in Effects¶
useEffect(() => {
const controller = new AbortController();
fetchData({ signal: controller.signal });
return () => controller.abort(); // Always cleanup
}, [dependencies]);
3. Use Promise.allSettled for Independent Requests¶
// If requests are independent, use allSettled
const results = await Promise.allSettled([fetchA(), fetchB(), fetchC()]);
// If requests depend on each other, use Promise.all
const [a, b] = await Promise.all([fetchA(), fetchB()]);
const c = await fetchC(a, b);
4. Type Your Event Emitters¶
// Define event map
interface EventMap {
'user:login': { userId: string };
'user:logout': void;
'data:update': { items: Item[] };
}
// Use typed emitter
const emitter = new TypedEventEmitter<EventMap>();
emitter.on('user:login', ({ userId }) => {
// TypeScript knows userId is string
});
5. Compose Small Functions¶
// Avoid one big function
function processData(input: string): number {
const parsed = parseInt(input, 10);
const doubled = parsed * 2;
const bounded = Math.min(doubled, 100);
return bounded;
}
// Prefer composed small functions
const parse = (s: string) => parseInt(s, 10);
const double = (n: number) => n * 2;
const cap = (max: number) => (n: number) => Math.min(n, max);
const processData = pipe(parse, double, cap(100));