Type inference with Firebase Realtime Database
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
-
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 -
Key extends keyof Schema = keyof Schema
The second type argument
Key
stores the keys of this object. This prefilled withkeyof T
which you can think of like storing the key type in a local variable. -
Key extends string ? ... : never
This filters out non-string keys such as
symbol
fromkeyof Schema
which aren’t allowed inside template literal types -
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.
-
? 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
-
: 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
-
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. -
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 theRest
of the path -
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 shouldnever
occur. -
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. -
LookupRefPath<Schema[Key], Rest>
If all of the above hold, recurse to lookup the rest of the path in this sub-object.
-
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.