dutzi.party

🎉

Things I've Learnt Building Firebase Functions Declarations

January 04, 2020

firebase-functions-declarations is a tool that creates declaration files for a given set of Firebase Functions.

When using it, instead of writing:

const response = (await firebase.functions().httpsCallable('myFunction')(data)).data

And not knowing what response is what or data should contain, you write:

import { myFunction } from './firebase-functions'

const response = await myFunction(data)

Where response will be typed to be whatever myFunction returns, and data will be type checked, making sure you provide the right type.

How Does It Work

When creating Firebase Functions you’d normally create an index file which imports all function and exports them. This is the entry point for Firebase Functions.

Each function module usually looks something like:

export default functions.https.onCall(async (data, context) {
  // firebase function body...
});

Ideally, my tool would extract the function passed to functions.https.onCall and infer its argument and return types.

This could be done using the TypeScript compiler and other tools (like ts-query or ts-morph), but because I only learnt about these methods after making this tool I went for a different approach. Maybe someday I’ll revisit this tool and introduce these changes, but for now it’s good enough.

Step 1 - Creating the Declarations

My approach was to require the author of the function to introduce the following change the function’s module - in addition to default export-ing the return value of functions.https.onCall, export the callback passed to functions.https.onCall as a function named impl.

For example, the following file is valid for my tool to work:

// ...

export async function impl(data: T, context: functions.https.CallableContext) {
  // do something, return some value of type U
}

export default functions.https.onCall(impl)

Then, using tsc, I can create declaration files for each one of the functions by running:

$ tsc ./functions/src/index.ts --outDir ./src/firebase-functions --emitDeclarationOnly --declaration

Now src/firebase-functions should contain .d.ts files for all modules within functions/src.

Step 2 - Creating The “Proxy” Module

Now that I have declarations files for the API, I can create a module that:

  • Imports firebase from firebase/app, since this file will eventually make the call to firebase.functions().httpsCallable.
  • Imports all functions. Here’s one import statement for example:
import { impl as myFunctionImpl } from './functions/my-function'

Note: Since I ran tsc emitting declaration files only, functions/my-function.js does not exist, but functions/my-function.d.ts does and that’s all I need.

  • Per each function, exports a function with the same name, that:

    • Accepts a data argument who’s type is inferred from the relevant .d.ts file
    • Returns a promise that resolves to what the function returns

This can be achieved using TypeScript’s Parameters and ReturnType utility types.

Here’s an exported function for example:

export async function myFunction(data?: Parameters<typeof myFunctionImpl>[0]) {
  return (await firebase.functions().httpsCallable('myFunction')(data)).data as ReturnType<typeof myFunctionImpl>;
}

And there you have it, a module that exposes typed functions for your API!

Caveats

As I wrote above, this whole making-the-user-do-stuff for this tool to work is not the best approach, especially since this work can be automated. But, the counter approach of making-me-do-stuff-for-the-user is also not the best one since I’m lazy.

But that’s not what I wanted to raise, the bigger problem with such a tool is that when the types exposed in a function’s signature are ones declared within the Firebase Functions package’s node_modules. In this case there is no way no simple way to share types betweens the functions and app’s codebase.

I made some pretty big projects so far using Firebase and haven’t encountered this issue. But I can image some pretty trivial use cases where this might be a deal-breaker.