Skip to content

axiosAdapter(axios) does not compile — the snippet in the adapter's own file header is rejected by tsc #708

Description

@rejifald

Scenario: axios-migration
Proofs: docs/scenarios/proofs/axios-migration/ (8 scripts, 151 checks, real axios 1.19.0)

axios-adapter.ts is 217 lines and was exercised by no test I could find — axiosAdapter( appears
in the whole docs/scenarios/proofs tree only inside a string literal, and there is no call site
anywhere in packages/
. That is almost certainly why this shipped.

1. The documented snippet is rejected by TypeScript

axios-adapter.ts:1-7 opens with:

import axios from 'axios';
import { axiosAdapter, stitch } from 'stitchapi';

const getUser = stitch({ url: '…/users/{id}', adapter: axiosAdapter(axios) });

Against axios 1.19.0, tsc 5.9.3:

error TS2345: Argument of type 'AxiosStatic' is not assignable to parameter of type 'AxiosLike'.
  Types of property 'request' are incompatible.
      Types of parameters 'config' and 'config' are incompatible.
        Type 'AxiosLikeConfig' is not assignable to type 'AxiosRequestConfig<unknown, any>'.
          Types of property 'responseType' are incompatible.
            Type 'string | undefined' is not assignable to type 'ResponseType | undefined'.

Not a strictness setting. Reproduced identically under strict: false and under
--strictFunctionTypes false. axios.create() and the two-argument defaults form fail the same
way; only a hand-written client compiles.

The root cause is one word. axios-adapter.ts:37:

responseType?: string;

is wider than axios's own ResponseType union, so AxiosLikeConfig is not assignable to
AxiosRequestConfig — and AxiosLike, documented at :31 as "structurally satisfied by
axios"
, is not.

The runtime is completely unaffected — every other measurement in this suite was taken through a
real axios instance at runtime. This is a types-only defect, which is the worst kind for adoption:
a developer pastes the documented line, gets a red squiggle in their editor, and concludes the
adapter is broken.

Ask: narrow the field to the values the adapter actually uses —
responseType?: 'arraybuffer' | 'json' | 'text' — or the full axios union. And add a type-level
regression: a .test-d.ts, or simply a call site somewhere inside packages/ that
pnpm -r check:types covers. With no call site in the repo, the public signature of this module is
currently unchecked.

2. AdapterResponse.url is never set on the axios path

Axios returns exactly { body, headers, status } where fetch returns four keys. Measured 12/12
shapes. Two consequences, both silent:

  • StitchError.url is always undefined on this transport.
  • The download filename fallback that reads the response URL has nothing to read.

Ask: set it from the request URL (axios exposes response.config.url, and the adapter already
holds req.url). Following a redirect makes the two differ, but the request URL is strictly better
than undefined.

3. defaults accepts anything, including a typo

AxiosLikeConfig has an [key: string]: unknown index signature, so defaults gets no
compile-time check at all. Measured: maxRedirects: 0 really did stop a 302 being followed, while
the typo maxRedirect: 0 beside it was accepted in silence and did nothing.

Ask: the index signature is deliberate (it is how arbitrary axios options pass through), so this
is mostly a doc note — but flagging the known-misspelling class is cheap, and narrowing the declared
fields (see §1) would at least make the known options checked.

4. The cancellation reason does not survive the transport

abort(new Error('user navigated away')) reaches the caller as "user navigated away" on
fetchAdapter and as "canceled" on axiosAdapter — axios substitutes its own CanceledError.
Every other cancellation cell is identical across the two transports (socket closes, status
undefined, no cause, a pre-dispatch abort records 1 circuit failure on 0 requests). So the one
channel carrying the caller's intent is the one the transport swap rewrites.

Ask: the adapter could read err.config?.signal?.reason and rethrow with it. Related to
#705, which asks for the abort reason to be
preserved generally.

5. "Behavior is identical across transports" is true of the encoder and not of the transport

The header claim (axios-adapter.ts:8-10) holds where it is specific: json, form and multipart
bodies are byte-identical on the wire, and every response parses to the same type and digest.
Multipart's 45-byte delta is exactly 3 × (47−32) — the boundary token, three times.

But across 12 shapes × 25 fields there are 66 divergent cells, including §2 above and: a POST
with no body arrives as application/x-www-form-urlencoded on axios and with no content-type on
fetch. (Cross-origin redirect credential-stripping agrees exactly — the one place a disagreement
would be a vulnerability.)

Ask: narrow the sentence to what is true — the body encoding and response parsing are shared —
rather than "behavior", which reads as a transport-swap guarantee.

Not a library defect, but worth a doc line

The findings that dominate the scenario are interactions between a user's interceptors and the
engine's declarations, and every one of them is a case where both layers are individually correct:

  • A reject-on-4xx interceptor has never run under plain axios (default validateStatus rejects
    first); axiosAdapter's validateStatus: () => true (:109) is what activates it. It then
    destroys the status: verdict: { accept: [404] } stops working, and retry.on: [503] — which
    sends 1 request on fetch — sends 3 on axios, because a throw is retried on attempt count alone.
  • An instance retry interceptor (1+2) under retry: { attempts: 3 } sends 9 requests; the engine
    reports attempts: 3, the interceptor reports retries: 6, and no number equals 9.
  • An auth interceptor runs after auth.apply and overwrites it, while onRequest — and every trace
    in the process — reports the token that lost. Off the collided header name the credential is
    doubled rather than overwritten.

A short "migrating from axios" note saying audit your interceptors first, and pass a clone would
save real debugging. The assembled migration is 30 lines and the load-bearing part is two eject
calls.

Reproduction

npx tsx docs/scenarios/proofs/axios-migration/c6-refusals.ts
npx tsx docs/scenarios/proofs/axios-migration/c1-rejecting-interceptor.ts

Real axios 1.19.0 (a workspace devDependency added for these proofs), one real node:http vendor on
127.0.0.1 whose ledger records raw request bytes, every cancellation sequenced on the vendor's own
arrival/hang-up promises rather than a timer. C6 runs tsc out of band so the compile failure is
measured, not asserted from reading types.

Which engine

axios-adapter.ts, http-adapter.ts and index.ts are byte-identical to origin/main, so
every citation here is valid on both trees. engine.ts/types.ts/resilience.ts have drifted; the
proofs' README carries the line-number map.

Source references (verified against origin/main)

  • axios-adapter.ts:1-7 — the header snippet that does not compile
  • axios-adapter.ts:8-10 — the "behavior is identical across transports" claim
  • axios-adapter.ts:31 — "structurally satisfied by axios" · :37responseType?: string;
  • axios-adapter.ts:49-51AxiosLike · :44-48AxiosLikeResponse (no url, no statusText)
  • axios-adapter.ts:109validateStatus: () => true
  • axios-adapter.ts:64-70 — the streaming refusal (exact, pre-transport, 0 requests)

Found while writing scenario 54 ("the 200 call sites you cannot rewrite this quarter") for the docs. Every claim is backed by a runnable offline proof in docs/scenarios/proofs/axios-migration/.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1Correctness bug, live on mainbugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions