-
-
Notifications
You must be signed in to change notification settings - Fork 443
Description
Overview
A proposal to add opt-in Result/Either-style error handling to Ky v2, aligning with modern patterns seen in Supabase, Zod, and the TC39 Safe Assignment Operator proposal.
Inspired by:
- TC39 Try Operator Proposal
- Issue #642 - React Query Integration
- Error handling patterns from Go and Rust
Motivation
Traditional try/catch blocks lead to:
- Deeply nested code
- Difficulty integrating with libraries like React Query
- Async complexity when accessing error details (
await error.response.json())
The Result pattern provides:
- Explicit error handling
- Better TypeScript inference
- Cleaner control flow
- Synchronous-like error access
Files to Modify
| File | Change Type | Description |
|---|---|---|
source/types/result.ts |
NEW | Result/Either type definitions |
source/types/ResponsePromise.ts |
Modify | Add .safe() method |
source/types/ky.ts |
Modify | Add SafeKyInstance type |
source/core/Ky.ts |
Modify | Implement .safe() logic |
source/index.ts |
Modify | Export new types, add ky.safe namespace |
Implementation Approaches
Approach 1: .safe() Method (Non-Breaking)
Add a .safe() method to the existing ResponsePromise:
const result = await ky.get('/api').safe()
const jsonResult = await ky.get('/api').json<User>().safe()Pros: Non-breaking, opt-in, minimal changes
Cons: Slightly verbose
Approach 2: ky.safe Namespace (Non-Breaking)
Add a parallel safe namespace with all HTTP methods:
const result = await ky.safe.get('/api')
const [error, data] = await ky.safe.get('/api').tuple()Pros: Clean API, explicit intent, non-breaking
Cons: More code to maintain
Approach 3: Result by Default (Breaking - v2)
Make Result the default return type:
// v2: Result by default
const result = await ky.get('/api')
// Opt-in to throwing behavior
const response = await ky.get('/api').throw()Pros: Modern, safer by default
Cons: Breaking change, requires major version bump
Detailed Implementation
1. New Type Definitions
Create source/types/result.ts:
import type {KyResponse} from './response.js';
import type {HTTPError} from '../errors/HTTPError.js';
import type {TimeoutError} from '../errors/TimeoutError.js';
/**
* Union of all Ky error types
*/
export type KyError = HTTPError | TimeoutError | Error;
/**
* Result type with discriminated union
*/
export type Result<T, E = KyError> =
| { ok: true; data: T; error: undefined; response: KyResponse }
| { ok: false; data: undefined; error: E; response?: KyResponse };
/**
* Tuple format for Go-style destructuring: [error, data]
*/
export type ResultTuple<T, E = KyError> = [E, undefined] | [undefined, T];
/**
* Promise that resolves to a Result, with convenience methods
*/
export type ResultPromise<T, E = KyError> = Promise<Result<T, E>> & {
/**
* Get result as tuple [error, data] for Go-style destructuring
*/
tuple: () => Promise<ResultTuple<T, E>>;
/**
* Parse response body as JSON and return Result
*/
json: <J = T>() => ResultPromise<J, E>;
/**
* Parse response body as text and return Result
*/
text: () => ResultPromise<string, E>;
/**
* Parse response body as Blob and return Result
*/
blob: () => ResultPromise<Blob, E>;
/**
* Parse response body as ArrayBuffer and return Result
*/
arrayBuffer: () => ResultPromise<ArrayBuffer, E>;
/**
* Parse response body as FormData and return Result
*/
formData: () => ResultPromise<FormData, E>;
/**
* Parse response body as raw bytes and return Result
*/
bytes: () => ResultPromise<Uint8Array, E>;
};2. Extend ResponsePromise
Modify source/types/ResponsePromise.ts:
import type {KyResponse} from './response.js';
import type {ResultPromise} from './result.js';
export type ResponsePromise<T = unknown> = {
arrayBuffer: () => Promise<ArrayBuffer>;
blob: () => Promise<Blob>;
formData: () => Promise<FormData>;
bytes: () => Promise<Uint8Array>;
json: <J = T>() => Promise<J>;
text: () => Promise<string>;
/**
* Convert to Result type - never throws, returns {ok, data, error}
*
* @example
* ```
* const result = await ky.get('/api').safe();
* if (result.ok) {
* console.log(result.data);
* } else {
* console.log(result.error.message);
* }
* ```
*/
safe: () => ResultPromise<KyResponse<T>>;
} & Promise<KyResponse<T>>;3. Add SafeKyInstance Type
Modify source/types/ky.ts:
import type {ResultPromise} from './result.js';
import type {KyResponse} from './response.js';
/**
* Safe Ky instance that returns Result instead of throwing
*/
export type SafeKyInstance = {
/**
* Fetch the given URL, returning Result instead of throwing
*/
<T>(url: Input, options?: Options): ResultPromise<KyResponse<T>>;
get: <T>(url: Input, options?: Options) => ResultPromise<KyResponse<T>>;
post: <T>(url: Input, options?: Options) => ResultPromise<KyResponse<T>>;
put: <T>(url: Input, options?: Options) => ResultPromise<KyResponse<T>>;
delete: <T>(url: Input, options?: Options) => ResultPromise<KyResponse<T>>;
patch: <T>(url: Input, options?: Options) => ResultPromise<KyResponse<T>>;
head: (url: Input, options?: Options) => ResultPromise<KyResponse>;
};
export type KyInstance = {
// ... existing methods ...
/**
* Safe namespace - all methods return Result instead of throwing
*
* @example
* ```
* const result = await ky.safe.get('/api/users');
* if (result.ok) {
* console.log(result.data);
* }
*
* // Or with tuple destructuring
* const [error, data] = await ky.safe.get('/api').tuple();
* ```
*/
readonly safe: SafeKyInstance;
};4. Implement in Ky.ts
Modify source/core/Ky.ts - Add after the responseTypes loop (~line 115):
// Add .safe() method to ResponsePromise
result.safe = (): ResultPromise<Response> => {
const createResultPromise = <T>(
promise: Promise<T>,
response?: Response
): ResultPromise<T> => {
const resultPromise = promise
.then((data): Result<T> => ({
ok: true,
data,
error: undefined,
response: response ?? data as unknown as KyResponse,
}))
.catch((error): Result<T> => ({
ok: false,
data: undefined,
error: error as KyError,
response: isHTTPError(error) ? error.response : undefined,
})) as ResultPromise<T>;
// Add tuple() method
resultPromise.tuple = async (): Promise<ResultTuple<T>> => {
const result = await resultPromise;
return result.ok
? [undefined, result.data]
: [result.error, undefined];
};
// Add body parsing methods that also return Result
for (const [type, mimeType] of Object.entries(responseTypes)) {
if (type === 'bytes' && typeof Response.prototype.bytes !== 'function') {
continue;
}
(resultPromise as any)[type] = <J = T>(): ResultPromise<J> => {
return createResultPromise(
result.then(async (res) => {
ky.request.headers.set('accept', ky.request.headers.get('accept') || mimeType);
if (type === 'json') {
if (res.status === 204) return '' as J;
const text = await res.text();
if (text === '') return '' as J;
return (options.parseJson?.(text) ?? JSON.parse(text)) as J;
}
return res[type as keyof Response]() as Promise<J>;
}),
undefined
);
};
}
return resultPromise;
};
return createResultPromise(result);
};5. Add ky.safe Namespace
Modify source/index.ts:
import type {SafeKyInstance} from './types/ky.js';
import type {ResultPromise} from './types/result.js';
const createInstance = (defaults?: Partial<Options>): KyInstance => {
const ky: Partial<Mutable<KyInstance>> = (input: Input, options?: Options) =>
Ky.create(input, validateAndMerge(defaults, options));
for (const method of requestMethods) {
ky[method] = (input: Input, options?: Options) =>
Ky.create(input, validateAndMerge(defaults, options, {method}));
}
// Create safe namespace
const safeMethods: Partial<SafeKyInstance> = {};
for (const method of requestMethods) {
safeMethods[method] = (input: Input, options?: Options) =>
Ky.create(input, validateAndMerge(defaults, options, {method})).safe();
}
// Make safe callable and add methods
ky.safe = Object.assign(
(input: Input, options?: Options) =>
Ky.create(input, validateAndMerge(defaults, options)).safe(),
safeMethods
) as SafeKyInstance;
ky.create = (newDefaults?: Partial<Options>) =>
createInstance(validateAndMerge(newDefaults));
ky.extend = (newDefaults?: Partial<Options> | ((parentDefaults: Partial<Options>) => Partial<Options>)) => {
if (typeof newDefaults === 'function') {
newDefaults = newDefaults(defaults ?? {});
}
return createInstance(validateAndMerge(defaults, newDefaults));
};
ky.stop = stop;
ky.retry = retry;
return ky as KyInstance;
};
export type {Result, ResultTuple, ResultPromise, KyError} from './types/result.js';
export type {SafeKyInstance} from './types/ky.js';Usage Examples
Basic Usage
import ky from 'ky';
const result = await ky.get('https://api.example.com/users').safe();
if (result.ok) {
console.log('Status:', result.response.status);
console.log('Data:', result.data);
} else {
console.error('Error:', result.error.message);
if (result.response) {
console.error('Status:', result.response.status);
}
}With JSON Parsing
interface User {
id: number;
name: string;
}
// Method 1: Chain .json() before .safe()
const result = await ky.get('/api/users').json<User[]>().safe();
if (result.ok) {
result.data.forEach(user => console.log(user.name));
}
// Method 2: Use safe namespace with .json()
const result2 = await ky.safe.get('/api/users').json<User[]>();
if (result2.ok) {
result2.data.forEach(user => console.log(user.name));
}Go-Style Tuple Destructuring
// Get result as [error, data] tuple
const [error, users] = await ky.safe.get('/api/users').json<User[]>().tuple();
if (error) {
console.error('Failed to fetch users:', error.message);
return;
}
console.log('Users:', users);With React Query
import {useQuery} from '@tanstack/react-query';
import ky from 'ky';
function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: async () => {
const result = await ky.safe.get('/api/users').json<User[]>();
if (!result.ok) {
// Error details are immediately available (no await needed)
throw new Error(result.error.message);
}
return result.data;
},
});
}Extended Instance
const api = ky.create({
prefixUrl: 'https://api.example.com',
headers: {
'Authorization': 'Bearer token'
}
});
// .safe() works on extended instances
const result = await api.safe.get('/users').json<User[]>();
// Or using .safe() method
const result2 = await api.get('/users').json<User[]>().safe();Error Type Narrowing
import ky, {isHTTPError, isTimeoutError} from 'ky';
const result = await ky.safe.get('/api/data');
if (!result.ok) {
if (isHTTPError(result.error)) {
console.log('HTTP Status:', result.error.response.status);
const body = await result.error.response.json();
console.log('Error body:', body);
} else if (isTimeoutError(result.error)) {
console.log('Request timed out');
} else {
console.log('Unknown error:', result.error);
}
}Breaking Changes for v2 (Optional)
For a more radical approach in v2, consider making Result the default:
Option A: Result by Default
// v2: All methods return Result by default
const result = await ky.get('/api/users');
// result is Result<Response>
// Opt-in to throwing behavior
const response = await ky.get('/api/users').throw();
// or
const response = await ky.unsafe.get('/api/users');Option B: Configurable Default
// Configure at instance level
const api = ky.create({
resultMode: true, // or 'result' | 'throw'
});
const result = await api.get('/api'); // Returns ResultMigration Path
// v1 (current)
try {
const data = await ky.get('/api').json();
} catch (error) {
// handle error
}
// v2 with backward compatibility layer
import {ky} from 'ky'; // New default (Result)
import {kyThrow} from 'ky/throw'; // Legacy behavior
// Or migration helper
import ky from 'ky';
const legacyKy = ky.throwOnError(); // Returns instance with old behavior