Typesafe Firebase Cloud Functions with TypeScript

October 5, 2019 | 4 min read

I often work with projects that use Firebase and Gatsby with TypeScript. While I love the combination, it's weird to call Firebase Cloud Functions (CFs) without having a clear definition of what it will return. Especially in a larger team, this is a problem as you don't want to dive into a function's code just to find out what it returns.

To solve this problem, we created a workflow to create typesafe Cloud Functions.

To set this workflow up, we use a new folder called shared-types that only contains type definitions and will be accessed by the project as well as the Cloud Functions. The folder structure in a normal project could look like this:

.
├── functions
├── node_modules
├── shared-types
├── src
└── static

Creating the Function Factory

The type magic happens in a simple function factory called createFunction. This function takes a CF name and creates a callable function with correct types. This function is located in a util folder in our project, but you may place it wherever you wish.

import { firebase } from '../firebase';

export const createFunction = <T = any, R = any>(
  name: string,
): ((data: T) => Promise<R>) => {
  const callable = firebase.functions().httpsCallable(name);
  return async (data: T) => (await callable(data)).data;
};

T is the parameter type and R is the return type, but more on that later.

Using the Factory

Let's say we want to write a CF that returns a user's posts from a specific year. First, let's create a shared type file, called posts.ts. This file contains parameter and return types for the corresponding CF that we'll write later.

export interface Post {
  userId: string;
  title: string;
  date: string;
  content: string;
}

export interface GetPostsParams {
  userId: string;
  year: number;
}

export interface GetPostsResult {
  posts: Post[];
}

We can now import and use these types in our CF file:

import * as firebase from 'firebase-admin';
import * as functions from 'firebase-functions';

import { GetPostsParams, GetPostsResult, Post } from '../../shared-types/posts';

export const getPosts = functions.https.onCall(
  async ({ userId, year }: GetPostsParams, ctx) => {
    const posts: Post[] = /* Firebase logic here */;
    return { posts } as GetPostsResult;
  },
);

To use this CF, we just need to call createFunction with the types we created:

const getUsers = createFunction<GetPostsParams, GetPostsResult>('getPosts');

The created function can now be called with type completion:

// posts will be of type Post[]
const posts = await getPosts({ userId: '0r4Hd99oKPci0WfTu7VkWxmROI03' });

Bonus Tips

Using this method, we can wrap and modify CF calls and their results. Here are three examples for what you could do with that.

Performance Monitoring

Firebase has a built-in module that can measure various timings. Measuring the time a function takes to execute on the client gives you a good glympse into the real performance of your app.

import { firebase } from '../firebase';

export const createFunction = <T = any, R = any>(
  name: string,
): ((data: T) => Promise<R>) => {
  const callable = firebase.functions().httpsCallable(name);

  return async (data: T) => {
    const trace = firebase.performance().trace(`functions:${name}`);
    trace.start();
    const result = await callable(data);
    trace.stop();
    return result.data;
  };
};

Proxying Functions

One of our projects has to be available to customers in China, where unfortunately, Google is blocked. To circumvent that block, we proxy all requests to https://ourdomain.tld/api/ to https://us-central1-our-project.cloudfunctions.net/ using Netlify's redirect file:

/api/* https://us-central1-our-project.cloudfunctions.net/:splat 200

Changing the CF endpoint is not possible yet as of this post, so we wrote our own library to handle it. Warning, long code block ahead:

export enum ErrorStatus {
  Ok = 'OK',
  InvalidArgument = 'INVALID_ARGUMENT',
  FailedPrecondition = 'FAILED_PRECONDITION',
  OutOfRange = 'OUT_OF_RANGE',
  Unauthenticated = 'UNAUTHENTICATED',
  PermissionDenied = 'PERMISSION_DENIED',
  NotFound = 'NOT_FOUND',
  Aborted = 'ABORTED',
  AlreadyExists = 'ALREADY_EXISTS',
  ResourceExhausted = 'RESOURCE_EXHAUSTED',
  Cancelled = 'CANCELLED',
  DataLoss = 'DATA_LOSS',
  Unknown = 'UNKNOWN',
  Internal = 'INTERNAL',
  NotImplemented = 'NOT_IMPLEMENTED',
  Unavailable = 'UNAVAILABLE',
  DeadlineExceeded = 'DEADLINE_EXCEEDED',
}

interface RawFunctionsError {
  error: {
    status: ErrorStatus;
    message: string;
  };
}

interface RawFunctionsResponse<T> {
  result: T | { success: boolean };
}

export class FunctionsError extends Error {
  constructor(message: string, public code: ErrorStatus) {
    super(message);
  }
}

const isError = <O>(
  input: RawFunctionsResponse<O> | RawFunctionsError,
): input is RawFunctionsError => !!(input as RawFunctionsError).error;

const isResult = <O>(
  input: RawFunctionsResponse<O> | RawFunctionsError,
): input is RawFunctionsResponse<O> => !!(input as RawFunctionsResponse<O>).result;

export const createFunction = <I = any, O = any, A = true>(name: string) => {
  // process.env.ENDPOINT is our API endpoint: https://ourdomain.tld/api
  const url = `${process.env.ENDPOINT}/${name}`;

  return async (data: I, token: A extends true ? string : undefined): Promise<O> => {
    const result = await fetch(url, {
      headers: {
        ...(token ? { authorization: `Bearer ${token}` } : {}),
        'content-type': 'application/json',
      },
      method: 'post',
      body: JSON.stringify({ data: data || null }),
    });

    const resultBody:
      | RawFunctionsResponse<O>
      | RawFunctionsError = await result.json();

    if (!result.ok && isError(resultBody)) {
      throw new FunctionsError(resultBody.error.message, resultBody.error.status);
    }

    if (isResult(resultBody)) {
      return resultBody.result as O;
    }

    throw new Error('Function response is neither error nor result.');
  };
};

This is an extreme example and in most cases, you don't need to use it. But it works. The way createFunction is invoked almost stays the same. We only need to pass authenticated CFs a user token in addition to the other parameters.