reply.send() types reject streams and Buffers that Fastify accepts at runtime
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.
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):
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:
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:
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:
- Do you want A, B, or neither?
- Should
Responsebe included? It is the fourth runtime case, but Fastify's shipped types currently reference no globalResponseorReadableStream, and adding one would make the published types depend on a global that older@types/nodeor a DOM-lesslibconfig 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.
Source: fastify/fastify