Skip to content

@hey-api/client-ky config type and runtime mismatches #3805

@SukkaW

Description

@SukkaW

Description

Config type accepts ky options, the runtime silently drops

Config is declared as:

export interface Config<T extends ClientOptions = ClientOptions>
  extends
    Omit<KyOptions, 'body' | 'headers' | 'method' | 'prefixUrl' | 'retry' | 'throwHttpErrors'>,
    CoreConfig {

Only six fields are stripped. Every other KyOptions field — including ky-only extras — is inherited into Config.

But the runtime in request() only hand-maps a specific RequestInit-like subset into the per-call kyOptions. The following are accepted by the type but never forwarded:

  • hooks
  • fetch
  • parseJson, stringifyJson
  • onDownloadProgress, onUploadProgress
  • context
  • searchParams

Thus, createClient({ hooks: { afterResponse: [...] } }) type-checks cleanly and silently does nothing at runtime. The user has to use kyOptions.hooks, but nothing in the type surface tells them that.

Explicit mapping clobbers custom ky instance defaults

The per-call kyOptions is rebuilt as a fresh object literal:

const kyOptions: KyOptions = {
  body: validBody as BodyInit,
  cache: opts.cache,
  credentials: opts.credentials,
  headers: opts.headers,
  integrity: opts.integrity,
  keepalive: opts.keepalive,
  method: opts.method,
  mode: opts.mode,
  redirect: 'follow',
  referrer: opts.referrer,
  referrerPolicy: opts.referrerPolicy,
  signal: opts.signal,
  throwHttpErrors: opts.throwOnError ?? false,
  timeout: opts.timeout,
  ...opts.kyOptions,
};

Every known field is written as its own property — even when opts.<field> is undefined. So given:

const myKy = ky.create({ credentials: 'include' });
createClient({ ky: myKy });

Every request emits credentials: undefined into kyOptions, which overrides the 'include' default the ky instance was configured with. Same for cache, mode, signal, timeout, integrity, keepalive, referrer, referrerPolicy.

Top-level retry is a lossy subset and clobbers kyOptions.retry

Exposed RetryOptions:

export interface RetryOptions {
  limit?: number;
  methods?: Array<'get' | 'post' | 'put' | 'delete' | 'patch' | 'head' | 'options' | 'trace'>;
  statusCodes?: number[];
}

drops ky's retryOnTimeout, delay, jitter, shouldRetry, backoffLimit. The only path to those is kyOptions.retry.

Worse, the retry mapping runs after ...opts.kyOptions is spread:

const kyOptions = {
  ...,
  ...opts.kyOptions,
};

if (opts.retry && typeof opts.retry === 'object') {
  kyOptions.retry = {
    limit: retryOpts.limit ?? 2,
    methods: retryOpts.methods,
    statusCodes: retryOpts.statusCodes,
  };
}

So a user who sets both top-level retry (for the ergonomic fields) and kyOptions.retry (for retryOnTimeout) silently loses the kyOptions half. The only working combo is "use kyOptions.retry exclusively and never set top-level retry" — which is non-obvious.

prefixUrl stripped, baseUrl reimplemented in parallel

When working with custom ky, prefixUrl is being ignored. The client reimplements URL prefixing as baseUrl inside its own buildUrl:

let url = (baseUrl ?? '') + pathUrl;

The fully-resolved absolute URL is then baked into a new Request(url, ...) and that Request object is what's passed to ky. Since ky's own prefixUrl handling only activates when the input is typeof === 'string', prefixUrl on a custom ky instance is dead code — it can never fire.


Individually each issue is small. Together they make createClient({ ky: myKy }) behave very differently from what users expect:

  • Fetch-level settings on the instance get clobbered (2).
  • prefixUrl on the instance is ignored (4).
  • Lossy retry on the instance can be wiped out by a top-level retry (3).
  • hooks, fetch, parseJson, etc. do survive on the instance — but those are exactly the fields the Config type currently advertises at top level and silently drops (1).

The practical result is that users reaching for ky.create() still need to duplicate most configuration at the createClient level. That inverts the composition story a custom ky instance is supposed to offer.

Reproducible example or configuration

No response

OpenAPI specification (optional)

No response

System information (optional)

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    bug 🔥Broken or incorrect behavior.

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions