Skip to content

Instantly share code, notes, and snippets.

@nandordudas
Created November 3, 2025 21:51
Show Gist options
  • Select an option

  • Save nandordudas/a93bcffdb872fb45d6aa9ea4b5c2009b to your computer and use it in GitHub Desktop.

Select an option

Save nandordudas/a93bcffdb872fb45d6aa9ea4b5c2009b to your computer and use it in GitHub Desktop.
import { Result } from 'typescript-result'
export interface AsyncState<E = Error> {
loading: boolean
error: E | null
lastFetch: number | null
retryCount: number
signal?: AbortSignal
controller?: AbortController
}
export function createAsyncState<E = Error>(): AsyncState<E> {
return {
loading: false,
error: null,
lastFetch: null,
retryCount: 0,
}
}
export interface WithAsyncStateOptions<T, E> {
onSuccess?: (data: T) => void | Promise<void>
onError?: (error: E) => void | Promise<void>
onFinally?: () => void | Promise<void>
resetRetryOnSuccess?: boolean
optimistic?: {
apply: () => void
rollback: () => void
}
maxRetries?: number
timeoutMs?: number
signal?: AbortSignal
}
class TimeoutError extends Error {
constructor(readonly timeoutMs: number) {
super(`Operation timed out after ${timeoutMs}ms`)
this.name = 'TimeoutError'
}
}
function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
signal?: AbortSignal,
): Promise<T> {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
let abortListener: (() => void) | null = null
const timeoutPromise: Promise<T> = Promise.resolve().then(() => {
return new Promise<T>((_, reject) => {
const onAbort = () => reject(new TimeoutError(timeoutMs))
controller.signal.addEventListener('abort', onAbort, { once: true })
})
})
if (signal && !signal.aborted) {
abortListener = () => controller.abort()
signal.addEventListener('abort', abortListener, { once: true })
}
return Promise.race([promise, timeoutPromise]).finally(() => {
clearTimeout(timeoutId)
})
}
function setupExternalSignal(
externalSignal: AbortSignal | undefined,
controller: AbortController,
): (() => void) | null {
if (externalSignal?.aborted) {
controller.abort()
return null
}
if (externalSignal && !externalSignal.aborted) {
const listener = () => controller.abort()
externalSignal.addEventListener('abort', listener, { once: true })
return listener
}
return null
}
function cleanupExternalSignal(
externalSignal: AbortSignal | undefined,
listener: (() => void) | null,
): void {
if (listener && externalSignal && !externalSignal.aborted) {
externalSignal.removeEventListener('abort', listener)
}
}
async function executeCallbacks<E>(
result: Result<unknown, E>,
callbacks: {
onSuccess?: (data: unknown) => void | Promise<void>
onError?: (error: E) => void | Promise<void>
onFinally?: () => void | Promise<void>
},
): Promise<void> {
if (result.isErr()) {
const onErrorResult = await Result.wrap(
() => callbacks.onError?.(result.error) ?? Promise.resolve(),
err => err as E,
)
if (onErrorResult.isErr()) {
console.error('onError callback failed:', onErrorResult.error)
}
}
const finallyResult = await Result.wrap(
() => callbacks.onFinally?.() ?? Promise.resolve(),
error => error as E,
)
if (finallyResult.isErr()) {
console.error('onFinally callback failed:', finallyResult.error)
}
}
export async function withAsyncState<T, E = Error>(
state: AsyncState<E>,
operation: (signal: AbortSignal) => Promise<T>,
options?: WithAsyncStateOptions<T, E>,
): Promise<Result<T, E>> {
const {
onSuccess,
onError,
onFinally,
resetRetryOnSuccess = true,
optimistic,
maxRetries = 3,
timeoutMs,
signal: externalSignal,
} = options ?? {}
const controller = new AbortController()
const abortListener = setupExternalSignal(externalSignal, controller)
state.loading = true
state.error = null
state.signal = controller.signal
state.controller = controller
optimistic?.apply()
const result = await Result.gen(
async function* () {
const operationWithSignal = operation(controller.signal)
const operationWithTimeout = timeoutMs
? withTimeout(operationWithSignal, timeoutMs, controller.signal)
: operationWithSignal
const wrapped = Result.wrap(
() => operationWithTimeout,
error => error as E,
)
const data = yield* await wrapped()
state.loading = false
state.lastFetch = Date.now()
if (resetRetryOnSuccess) {
state.retryCount = 0
}
return data
},
).mapError(error => {
state.loading = false
state.error = error as E
state.retryCount++
if (state.retryCount > maxRetries) {
optimistic?.rollback()
}
return error
}).andThen(data =>
Result.wrap(
() => onSuccess?.(data) ?? Promise.resolve(),
error => error as E,
).map(() => data),
).mapError(error => {
console.error('onSuccess callback failed:', error)
return error
})
await executeCallbacks(result, { onError, onFinally })
cleanupExternalSignal(externalSignal, abortListener)
return result
}
export function isStale(state: AsyncState, staleTime = 5 * 60 * 1_000): boolean {
if (!state) {
throw new Error('State object is required')
}
if (staleTime < 0) {
throw new Error('staleTime must be non-negative')
}
if (!state.lastFetch) {
return true
}
return Date.now() - state.lastFetch > staleTime
}
export function resetAsyncState<E = Error>(state: AsyncState<E>): void {
if (!state) {
throw new Error('State object is required')
}
state.controller?.abort()
Object.assign(state, createAsyncState<E>())
}
export function canRetry(state: AsyncState, maxRetries = 3): boolean {
if (!state) {
throw new Error('State object is required')
}
return state.retryCount < maxRetries && state.error !== null
}
export function abortOperation(state: AsyncState): void {
if (!state) {
throw new Error('State object is required')
}
state.controller?.abort()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment