Type inference with Firebase Realtime Database

TL;DR

TypeScript Playground (demo)

Let’s say you’re adding realtime networked state to your app using Firebase Realtime Database. Here’s how you might read a snapshot of data using the Firebase JavaScript SDK:

      const countRef = ref(database, "count")
const countSnapshot = await get(countRef)
const count = countSnapshot.val()
// ^? const count: any

    

We’re not sure that the path "count" points to any real data, or that it contains numeric values. You might just keep track of this in your head and cast it to a number:

      const count = countSnapshot.val() as number

    

But what happens when we decide to change where we write this state, or what type of data it holds? Will you remember to update all your path references and casts? Will you remember to check for uninitialized or missing values on each read?

Wouldn’t it be nice if we could define a schema for the data we intend to store, so our tooling can gently remind us of where data lives and what type it is?

      type Database = {
	count: number
}

const count = await getStrict("count")
// ^? const count: number | null

const count = await getStrict("cout")
// Type error

    

You could reach for a library like FireSageJS which provides type-friendly utilities for defining your data schema and safely reading and writing data. However, maybe you don’t want an extra dependency for this. In my case, the version of Firebase used by FireSageJS was incompatible with the version I needed for reactfire. Let’s explore how you might set up your own utility types to achieve the above example.

Inferring Valid Paths

Indexing into a Firebase Realtime Database is like indexing into a JavaScript object, except that you use a string path delimited with slashes. In order to make these paths safe, we need something stricter than string. Let’s make a type that infers the exact string values that are allowed.

      /*
 * Generates a union of all possible ref paths to index an object
 * E.g. RefPath<{ foo: { bar: ... }}> ==> 'foo' | 'foo/bar'
 */
type RefPath<
	Schema extends Record<string, unknown>,
	Key extends keyof Schema = keyof Schema,
> = Key extends string
	? Schema[Key] extends Record<string, unknown>
		? Key | `${Key}/${RefPath<Schema[Key]>}`
		: Key
	: never

    

This utility type produces a union of all valid path arguments for ref() given some database schema type.

Breakdown of how this works
  1. Schema extends Record<string, unknown>

    The first type argument Schema holds the shape of the database (or its children), which is some record with string keys

  2. Key extends keyof Schema = keyof Schema

    The second type argument Key stores the keys of this object. This prefilled with keyof T which you can think of like storing the key type in a local variable.

  3. Key extends string ? ... : never

    This filters out non-string keys such as symbol from keyof Schema which aren’t allowed inside template literal types

  4. Schema[Key] extends { [key: string]: unknown } ? ... : ...

    This checks whether we should recurse. If the value at the current key is some object with string keys, then we need to recurse to include those keys.

  5. ? Key | `${Key}/${RefPath<Schema[Key]>}`

    Recursive case. Return the current key as well as the recursive concatenation of the current key with any paths below it, separated by a slash

  6. : Key

    Base case. Return just the current key

Here’s an example of how you might use this in a helper function:

      type Database = {
	foo: {
		bar: string
	}
	buzz: number
}

function refStrict<T extends RefPath<Database>>(path: T) {
	return ref(database, path)
}

refStrict("foo/bar") // path gets autocomplete
refStrict("not/a/real/path") // this produces an error

    

Inferring Data Types

So far we can safely type paths for ref() but we still don’t know what type of data we can read or write at those paths. Let’s write a type that looks up the type from our schema given a path.

      /**
 * Splits a string path into segments and recursively indexes into a schema
 *
 * E.g. given
 * type Schema = { foo: { bar: number } }
 *
 * LookupRefPath<"foo">     ==> { bar: number }
 * LookupRefPath<"foo/bar"> ==> number
 */
type LookupRefPath<
	Schema extends Record<string, unknown>,
	Path extends string,
> = Path extends `${infer Key}/${infer Rest}`
	? Key extends keyof Schema
		? Schema[Key] extends Record<string, unknown>
			? LookupRefPath<Schema[Key], Rest>
			: never
		: never
	: Path extends keyof Schema
	? Schema[Path]
	: never

    
Breakdown of how this works
  1. type LookupRefPath<Schema extends Record<string, unknown>, Path extends string>

    We’re defining a type with two type arguments. Schema is the shape of some level of the database, Path is the slash-separated path.

  2. Path extends `${infer Key}/${infer Rest}` ? ... : ...

    This checks if the path consists of two or more parts, in which case it separates the topmost Key from the Rest of the path

  3. Key extends keyof Schema ? ... : never

    This verifies that the parsed key exists in the schema (otherwise it could be any string). A key that doesn’t exist in the schema would be invalid which should never occur.

  4. Schema[Key] extends Record<string, unknown> ? ... : never

    This verifies that the value at that key is a valid schema shape to satisfy our LookupRefPath<...> definition.

  5. LookupRefPath<Schema[Key], Rest>

    If all of the above hold, recurse to lookup the rest of the path in this sub-object.

  6. Path extends keyof Schema ? Schema[Path] : never

    If the path doesn’t contain a slash, the whole thing represents a single key. Lookup the value at this key if the key exists in the schema. Otherwise the key is invalid which should never occur.

The Firebase SDK uses null to to signify an empty value, which may or may not be an intentionally empty value. Maybe you read from a valid path that’s uninitialized, or maybe you read from an invalid path—in either case Firebase returns null.

Also if we’re reading an object, there’s no guarantee that all of the keys of that object have been initialized with data. Since Firebase can’t guess keys that it hasn’t seen, those values would be undefined when we try accessing them.

So for added safety, let’s expand the resolved type to include null and nested properties to include null and undefined. This encourages us to check for missing or uninitalized values and gracefully fallback to defaults.

      type RecursiveNullablePartial<T> = {
	[P in keyof T]?: RecursiveNullablePartial<T[P]> | null
}

type RefValue<
	Schema extends Record<string, unknown>,
	Path extends RefPath<Schema>,
> = RecursiveNullablePartial<LookupRefPath<Schema, Path>> | null

    

I find that when I define helper functions, I have to wrap RefValue with a Prettify utility type to make the type hint expand the underlying definitions.

      /**
 * Adapted from
 * https://gist.github.com/palashmon/db68706d4f26d2dbf187e76409905399
 */
type Prettify<T> = NonNullable<{
	[K in keyof T]: T[K]
}>

    

Putting it together

Here’s some examples of how you can use these utility types to add safety to your reads and writes.

      async function getValueStrict<Path extends RefPath<Database>>(path: Path) {
	const snapshot = await get(ref(database, path))
	return snapshot.val() as Prettify<RefValue<Database, Path>>
}

async function setValueStrict<Path extends RefPath<Database>>(
	path: Path,
	value: Prettify<RefValue<Database, Path>>,
) {
	const snapshot = await set(ref(database, path), value)
}

async function onValueStrict<
	Path extends RefPath<Database>,
	Value = Prettify<RefValue<Database, Path>>,
>(path: Path, callback: (value: Value) => void) {
	return onValue(ref(database, path), (snapshot) => {
		callback(snapshot.val() as Value)
	})
}

    

With that hard part out of the way you can now reap the benefits.

Here’s an exampe of how you can can subscribe to a typed value:

      type Database = {
	room: {
		count: number
	}
}

onValueStrict("room/count", (count) => {
	console.log("The count is", count)
	// ^? (parameter) count: number | null
})

    

It even sort of works when path segments include broader types like string, albeit without autocomplete.

      type Database = {
	[room: string]: {
		count: number
	}
}

onValueStrict("myRoom/count", (count) => {
	console.log("The count is", count)
	// ^? (parameter) count: number | null
})

    

In a team project you might wish to enable the "no-restricted-imports" ESLint rule so developers get a warning when they forget to use the typed helper functions:

      // .eslintrc.js

module.exports = {
  ...
  rules: {
    ...
    "no-restricted-imports": [
      "warn",
      { paths: ["firebase"], patterns: ["firebase/*"] },
    ],
  }
}

    

Like many things in TypeScript, this only provides the illusion of type safety. If an external system reads or writes to the same Firebase database, then there’s no guarantee that the data you’re reading matches the schema. Depending on your use case, you may wish to add a runtime validation layer with something like Zod when reading from a untrusted source.

But in a closed, trusted system, this is at least an upgrade from the mystery box you’d otherwise be dealing with.