Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Either/Result Pattern Implementation Plan for Ky v2 #799

@AlphaLawless

Description

@AlphaLawless

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:

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 Result

Migration 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

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions