#7009·fastify

reply.send() types reject streams and Buffers that Fastify accepts at runtime

Author: kavish-19Created Sep 5, 2026Updated Sep 7, 2026

Prerequisites

  • I have written a descriptive issue title
  • I have searched existing issues to ensure the issue has not already been raised

Issue

reply.send() rejects, at the type level, several payloads that Fastify accepts at runtime. Whenever a route has a Reply type (from a generic or a response schema), SendArgs requires the payload to be exactly that type, so sending a stream or a Buffer is a compile error even though Fastify sends it correctly.

typescript
server.get<{ Reply: string }>('/csv', async (request, reply) => {
  reply.send(Readable.from(['a,b,c']))
  //         ^ TS2345: Argument of type 'Readable' is not assignable to parameter of type 'string'.
})

The same route works at runtime. Using app.inject() on a route whose response schema is { type: 'string' }:

stream  -> 200 "a,b,c"
buffer  -> 200 "hello"

This is expected, because Reply.prototype.send forwards these payloads to the onSend hook before the serializer is consulted (lib/reply.js#L180-L200):

javascript
if (
  typeof payload.pipe === 'function' ||        // node:stream
  typeof payload.getReader === 'function' ||   // node:stream/web
  (typeof payload === 'object' && toString.call(payload) === '[object Response]')
) {
  onSendHook(this, payload)
  return this
}

if (payload.buffer instanceof ArrayBuffer) {   // Buffer / typed arrays
  ...
  onSendHook(this, payloadToSend)
  return this
}

So the declared Reply type never governs these four shapes at runtime, but SendArgs acts as though it does:

typescript
export type SendArgs<ReplyType> = unknown extends ReplyType
  ? [payload?: ReplyType]
  : [ReplyType] extends [undefined | void]
      ? [payload?: ReplyType]
      : [payload: ReplyType]

This was reported downstream as fastify/fastify-type-provider-json-schema-to-ts#108 (streaming a CSV from a route with a { type: 'string' } response schema), where @bcomnes asked how it would be represented with json-schema-to-ts. I don't think it can be, or should be: the type provider only supplies the serializer type, and these payloads bypass serialization, so the fix belongs here rather than in a provider.

SendArgs came from #6432, and #6526 is already restoring one payload shape (PromiseLike) that the tightening excluded. This is the same class, for streams and buffers.

Two possible shapes, and why I'm asking first

I have both working locally with red-then-green type tests, but each imposes churn on existing assertions, so I'd rather have a steer than pick for you.

A — union into SendArgs:

typescript
export type RawReplyPayload =
  | ArrayBufferView
  | { pipe: (...args: any[]) => unknown }
  | { getReader: (...args: any[]) => unknown }

// ...
      : [payload: ReplyType | RawReplyPayload]

B — a second send() overload taking RawReplyPayload.

Measured with tstyche on main:

A (union) B (overload)
Signature assertions needing update 10 12
Existing @ts-expect-error messages that change 8 0
Message for a genuinely wrong payload not assignable to 'string | RawReplyPayload' No overload matches this call.

B preserves every existing diagnostic, but an overloaded signature is awkward to assert in the current expect(reply.send).type.toBe<...>() style. A keeps one signature and the test updates are mechanical, but it makes the message noisier for every mistyped payload. Both still reject invalid payloads — I have negative tests covering send(42) and send({ nope: true }).

Two open questions:

  1. Do you want A, B, or neither?
  2. Should Response be included? It is the fourth runtime case, but Fastify's shipped types currently reference no global Response or ReadableStream, and adding one would make the published types depend on a global that older @types/node or a DOM-less lib config may not provide. I left it out for that reason and matched the other three structurally, mirroring the runtime's own duck typing so custom stream-likes qualify too.

Happy to send a PR for whichever you prefer.