loading...

Is Declaration Merging and Generic Inheritance at the Same Time Impossible?

ethanarrowood profile image Ethan Arrowood ・3 min read

Fastify is a fast and low overhead web framework for Node.js. It supports http, https, and http2 server types. The Fastify v3 type system provides a generic property so the user can specify which type of server they want to create; this generic property is then used to determine the type of the server request and reply objects.

The following definitions are a simplified abstraction from the actual Fastify v3 type system. You can explore the complete source code here

The start of the definition defines the list of types the server generic can be, as well as the main function fastify.

import http from 'http'
import https from 'http'
import http2 from 'http2'

type ServerTypes = http.Server | https.Server | http2.Http2Server

declare function fastify<Server extends ServerTypes>(): FastifyInstance<Server>

The FastifyInstance definition depends on two generic expressions, one for determining the base request type, and the other for reply.

type RequestExpression<Server extends ServerTypes> = (
  Server extends http.Server | https.Server
  ? http.IncomingMessage
  : http2.Http2ServerRequest 
)

type ReplyExpression<Server extends ServerTypes> = (
  Server extends http.Server | https.Server
  ? http.ServerResponse
  : http2.Http2ServerResponse
)

interface FastifyInstance<
  Server extends ServerTypes,
  Request = RequestExpression<Server>,
  Reply = ReplyExpression<Server>
> {
  request: FastifyRequest<Request>,
  reply: FastifyReply<Reply>
}

These Request and Reply generics are then passed to the FastifyRequest and FastifyReply definitions. These make use of generic inheritance to add additional properties to the base request and reply types.

type FastifyRequest<Request> = Request & {
  body: unknown,
  query: unknown
}

type FastifyReply<Reply> = Reply & {
  sent: boolean,
  code(c: number): FastifyReply<Reply>
}

Fastify supports plugins for decorating the server, request, and reply instances with additional properties from the user.

function myPlugin (inst, opts, next) {
  inst.decorateRequest('myPluginProp', 'super_secret_string')
  inst.decorareReply('myPluginProp', 5000)
}

But how can we update the type system to acknowledge these new properties? Additionally, to support module based plugins (i.e. downloadable from npm), the type override should just work by importing the plugin into a project (i.e. import myPlugin from 'myPlugin'). We can try to use declaration merging on the FastifyRequest and FastifyReply types:

declare module 'fastify' {
  type FastifyRequest = {
    myPluginProp: string
  }
  type FastifyReply = {
    myPluginProp: number
  }
}

Sadly this does not work; you cannot merge type declarations. What if we try rewriting the type declarations as interfaces?

interface FastifyRequest<Request> extends Request {
  raw: Request;
  body: unknown;
  query: unknown;
}

interface FastifyReply<Reply> extends Reply {
  raw: Reply;
  sent: boolean;
  code(c: number): FastifyReply<Reply>;
}

Sadly, this also does not work; it throws an error:

An interface can only extend an object type or intersection of object types with statically known members.

If we use types, then we can't support declaration merging. And if we use interfaces, then we can't support generic inheritance. 🤔

💡 What if we use both types and interfaces?

  1. Encapsulate the custom properties into interfaces
interface FastifyRequestInterface<Request> {
  raw: Request;
  body: unknown;
  query: unknown;
}

interface FastifyReplyInterface<Reply> {
  raw: Reply;
  sent: boolean;
  code(c: number): FastifyReply<Reply>;
}
  1. Replace the { ... } part of the type declaration with these new interfaces
type FastifyRequest<Request> = Request & FastifyRequestInterface<Request>

type FastifyReply<Reply> = Reply & FastifyReplyInterface<Reply>

Now, if the user wants to add custom properties too this they can use declaration merging on the FastifyRequestInterface and FastifyReplyInterface interfaces, and the type declarations can still inherit from the generic parameters!

declare module 'fastify' {
  interface FastifyRequestInterface {
    myPluginProp: string
  }
  interface FastifyReplyInterface {
    myPluginProp: number
  }
}

And in some implementation file:

import fastify from 'fastify'
import myPlugin from 'myPlugin'

const server = fastify()

server.register(myPlugin)

server.get('/', (request, reply) => {
  request.myPluginProp // -> ✅ string
  reply.myPluginProp // -> ✅ number
})

🎉 Thus, no it is not impossible to support both declaration merging and generic inheritance at the same time!

--

Thank you for reading! If you enjoyed this article consider following myself on Twitter @ArrowoodTech.

To learn more about Fastify checkout our GitHub repository or our website.

Posted on by:

ethanarrowood profile

Ethan Arrowood

@ethanarrowood

Microsoft Software Engineer by day, JavaScript/TypeScript/Node.js open source contributor by night.

Discussion

markdown guide
 

I really would like to use this solution but I don't see enough information in the code snippets to apply to my specific problem which already includes declaration merging. Could you please publish a full example on Github?

 
 

You can see a much more verbose example in the fastify type definitions on the master branch. github.com/fastify/fastify/blob/ma...

 

Why not just make a ServerBuilder class with a register method which returns a ServerBuilder with the plugin added to some array and some type params changed? Then a .create method would just return a server having the type parameters of the last ServerBuilder instance in the chain

 

Fastify is not written in TypeScript so we need to come up with alternative ways of defining types!

 

Amazing. Sure not something you would use everyday, but an incredible clever solution for something complex as this. Thank you.

 

Yeah exactly! When I initially discovered this problem I found a lot of StackOverflow responses saying 'this is impossible' or 'not supported'.