Skip to main content

Type-Safe Firestore Paths in a Shared Package: Let the App Register Its Own Types

ยท 8 min read
Gergely Sipos
Frontend Architect

On the OTP project we are moving to a monorepo with several microfrontends. At least three apps talk to Firestore, and all of them had their own copies of the same hooks: subscribe to a document, subscribe to a query, wait for the current user. The obvious move is to pull those hooks into a shared @repo/react-firestore package.

The catch is typing. Each app owns its own Firestore schema: workspaces/{id}/projects, users/{id}/chats, and so on. The shared package must not know those paths. But if the hook just takes path: string, we lose the typo protection we had when each app typed its own hooks.

This post shows the pattern we used: the package declares an empty registry, each app fills it in through declaration merging, and the package's hooks derive their parameter types from whatever the app registered.

The problem in one sentenceโ€‹

A library needs a type that only its consumer knows, and the consumer must not have to pass it as a generic on every call.

Generics on every call (useFirestoreDoc<ProjectPaths>(...)) work but are noisy and easy to forget. A factory (createFirestoreHooks<Paths>()) works too, but every app then has to re-export its own wrapped hooks, and the shared package can no longer export ready-made hooks or components. We wanted the package's exports to be usable directly and still be typed per app.

TypeScript has a well-known answer for this: declaration merging into a registry interface. Libraries such as TanStack Router (Register), Vue (ComponentCustomProperties) and i18next (CustomTypeOptions) use the same idea.

Step 1: the package declares an empty registryโ€‹

In the shared package, packages/react-firestore/src/types/firestore-types.d.ts:

declare module '@repo/firestore' {
import { DocumentData, DocumentReference, QueryDocumentSnapshot } from 'firebase/firestore'

namespace OtpFirestore {
interface Registry {}

type CollectionPath = Registry extends {
collectionPaths: infer Paths extends string
}
? Paths
: never

type DocPath<C extends CollectionPath = CollectionPath> = `${C}/${string}`
type DocRef<T> = DocumentReference<T, DocumentData>
type QDocSnap<T> = QueryDocumentSnapshot<T, DocumentData>
}
}

Three things are going on here.

declare module '@repo/firestore' is an ambient module. No package called @repo/firestore needs to exist on disk. The file has no top-level import or export, so it is a global script, and the declare module block creates a module name that any file in the program can import from. It works as a shared, named place for types that both the package and the apps can reach.

interface Registry {} is the extension point. Interfaces are open in TypeScript: two interface Registry declarations in the same scope merge into one. The package ships it empty on purpose.

CollectionPath reads the registry lazily. The conditional type asks: "does Registry have a collectionPaths property whose type is a string?" If yes, infer Paths extends string pulls that string union out. If not, the result is never. Conditional types on an interface are evaluated where they are used, after all merges are applied, so the package code never has to know what the app will add.

The never fallback is a deliberate choice. If an app forgets to register its paths, every hook call fails to compile instead of silently accepting any string.

DocPath<C> is a template literal type: a collection path, a slash, and any string for the document ID. DocRef and QDocSnap are short aliases for the Firebase types so hook signatures stay readable.

Step 2: the app describes its paths as string literal typesโ€‹

In the app (or in a shared schema package such as our otp-content-generation), the collection paths are template literal types built from each other:

type WorkspacesCollection = 'workspaces'
type WorkspaceProjectCollection = `${WorkspacesCollection}/${WorkspaceId}/projects`
type ProjectTextAssetsCollection = `${WorkspaceProjectCollection}/${ProjectId}/text_assets`
type ProjectTextAssetVersionsCollection = `${ProjectTextAssetsCollection}/${AssetId}/versions`
// ...

type CollectionPath =
| UsersCollection
| UserChatsCollection
| WorkspaceProjectCollection
| ProjectTextAssetsCollection
| ProjectTextAssetVersionsCollection
// ...

WorkspaceId, ProjectId and AssetId are aliases of string, so WorkspaceProjectCollection resolves to `workspaces/${string}/projects`. That is a pattern type: it accepts workspaces/abc/projects and rejects workspace/abc/projects.

Building each path from its parent keeps the hierarchy in one place. Rename projects once and every nested path follows.

Step 3: the app registers its pathsโ€‹

In the app, apps/content-generation-app/src/hooks/firestore/general/firestore-type-setup.ts:

import '@repo/react-firestore/types/firestore-types'
import { OtpStore } from 'otp-content-generation'

declare module '@repo/firestore' {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace OtpFirestore {
interface Registry {
collectionPaths: OtpStore.CollectionPath
}
}
}

This file has top-level imports, so it is a module. Inside a module, declare module '@repo/firestore' is no longer a new ambient module but a module augmentation: it adds to the existing declaration. The namespace OtpFirestore merges with the package's namespace, and interface Registry merges with the empty one. After this, Registry has a collectionPaths property, and OtpFirestore.CollectionPath resolves to the app's path union everywhere in the app's program.

The side-effect import on the first line matters. Augmentation only works on a module TypeScript can already see, so this line pulls the package's .d.ts into the program first.

The ESLint comment is needed because @typescript-eslint/no-namespace blocks namespaces by default. Here the namespace is not used as a runtime code organizer but as a merge target for types, and that is one of the few places where namespaces are still the right tool.

Step 4: the shared hooks use the registryโ€‹

Now the package can write hooks against OtpFirestore without knowing any app:

import { DocumentReference, DocumentSnapshot, doc, onSnapshot } from 'firebase/firestore'
import type { Firestore } from 'firebase/firestore'
import { OtpFirestore } from '@repo/firestore'
import { useEffect, useState } from 'react'
import { useFirestoreCurrentUser } from './use-firestore-current-user'

export const useFirestoreDoc = <T, C extends OtpFirestore.CollectionPath>(
firestore: Firestore,
path: OtpFirestore.DocPath<C>,
) => {
const [snapshot, setSnapshot] = useState<DocumentSnapshot<T> | null>(null)
const user = useFirestoreCurrentUser(firestore)

useEffect(() => {
if (!user) return
try {
const docRef = doc(firestore, path) as DocumentReference<T>
const unsubscribe = onSnapshot(docRef, { includeMetadataChanges: true }, setSnapshot, (error) =>
console.error('Error in useFirestoreDoc', path, error),
)
return unsubscribe
} catch (error) {
console.warn('Error in useFirestoreDoc', error)
}
}, [path, user])

return snapshot
}

Inside the content generation app, the editor now checks every call:

useFirestoreDoc<Project, OtpStore.WorkspaceProjectCollection>(db, `workspaces/${workspaceId}/projects/${projectId}`) // โœ…
useFirestoreDoc<User, OtpStore.UsersCollection>(db, `users/${userId}`) // โœ…
useFirestoreDoc<Project, OtpStore.WorkspaceProjectCollection>(db, `workspace/${workspaceId}/projects/${projectId}`) // โŒ typo in "workspace"
useFirestoreDoc<User, OtpStore.UsersCollection>(db, 'users') // โŒ collection path, not a document path

Without explicit generics, C falls back to the whole CollectionPath union, so the path is still checked against every registered collection. Another app registers a different union and gets its own checks from the same compiled package.

Why this setup is usefulโ€‹

  • The package stays schema-agnostic. @repo/react-firestore has no dependency on any app's data model, so it can be shared by three or more apps without becoming a dumping ground for their types.
  • Each app is typed by its own schema. Apps don't see each other's collections, and a path that is valid in one app is a compile error in another.
  • No per-call boilerplate. The registration happens once per app. Hooks, components and helpers exported by the package all pick it up.
  • Fail closed. Because the fallback is never, an app that forgets to register gets compile errors on the first hook call, not runtime errors on the first typo.
  • The schema is still the source of truth. Paths are built from each other as template literals, so a renamed collection breaks every outdated call site at compile time.

Things to watch out forโ€‹

  • The registration file must be in the program. If firestore-type-setup.ts is not covered by the app's tsconfig include, the augmentation never applies and every call resolves to never. Keep it next to the app's other Firestore setup code.
  • One registry per program. If two apps end up in the same TypeScript program (for example, one root tsconfig that includes everything), their registrations clash with "Subsequent property declarations must have the same type". Give each app its own tsconfig, which a Turborepo setup does anyway.
  • ${string} is loose. It matches the empty string and strings with slashes, so users/ and users/a/b/c both pass. The types catch wrong collection names and missing segments, not every malformed path. Firestore still validates at runtime.
  • Document data is not linked to the path yet. T is passed separately, so nothing stops useFirestoreDoc<User> on a projects path. A natural next step is to register a map instead of a union (collections: { [P in Path]: DocType }) and derive T from the path.

Summaryโ€‹

The pattern has three parts. An ambient module gives the package and the apps a shared, named place for types. An empty interface in that module is the extension point, and a conditional type reads from it lazily with a safe never fallback. Each app fills the interface through module augmentation with its own template literal path types.

The result is one shared Firestore package whose hooks are as strictly typed as if each app had written them itself.