Skip to content

TypeScript Client Guide ​

This guide provides a comprehensive overview of using FireSchema with the typescript-client target, designed for web frontends or Node.js applications using the Firebase Client SDK (v9+ modular).

Overview & Setup ​

Target Audience ​

Use this target if you are building:

  • Web applications (React, Vue, Angular, Svelte, etc.) interacting directly with Firestore.
  • Node.js client applications (e.g., scripts, utilities) using the standard firebase package.

Prerequisites ​

  • Completion of the Installation guide (CLI tool installed).
  • An existing TypeScript project.
  • The firebase package (v9+) installed: npm install firebase
  • The @shtse8/fireschema-ts-client-runtime package installed: npm install @shtse8/fireschema-ts-client-runtime
  • Initialized Firebase app and Firestore instance:
typescript
// Example _setup.ts (or similar init file)
import { initializeApp, FirebaseApp } from 'firebase/app';
import { getFirestore, Firestore } from 'firebase/firestore';

// Replace with your actual Firebase config
const firebaseConfig = {
  apiKey: "...",
  authDomain: "...",
  projectId: "...",
  // ... other config
};

let firestoreInstance: Firestore;

export function initializeTestEnvironment(): Firestore {
  if (!firestoreInstance) {
      const app: FirebaseApp = initializeApp(firebaseConfig);
      firestoreInstance = getFirestore(app);

      // Optional: Connect to Firestore Emulator if using
      // import { connectFirestoreEmulator } from 'firebase/firestore';
      // connectFirestoreEmulator(firestoreInstance, '127.0.0.1', 8080);
  }
  return firestoreInstance;
}

// Export the initialized instance for use elsewhere
export const firestore = initializeTestEnvironment();

SDK & Runtime ​

  • Supported SDK: firebase (v9+ modular JS SDK)
  • Runtime Package: @shtse8/fireschema-ts-client-runtime

Configuration (fireschema.config.json) ​

Ensure your configuration file specifies the typescript-client target:

json
{
  "schema": "./firestore.schema.json",
  "outputs": [
    {
      "target": "typescript-client",
      "outputDir": "./src/generated/firestore", // Example output directory
      "options": {
        "dateTimeType": "Timestamp" // Or "Date"
      }
    }
  ]
}

Generation ​

Run the generator: npx fireschema generate

This creates files like users.collection.ts, users.types.ts, etc., in the output directory.


CRUD Operations ​

Basic Create, Read, Update, and Delete operations.

(Setup assumed from above)

typescript
import { UsersCollection } from '../generated/firestore/users.collection'; // Adjust path
import { UserData, UserAddData } from '../generated/firestore/users.types'; // Adjust path
import { DocumentReference, Timestamp, SetOptions, serverTimestamp, FieldValue } from 'firebase/firestore';

const usersCollection = new UsersCollection(firestore);

Create (Add) ​

Use add() for new documents with auto-generated IDs. Requires UserAddData.

typescript
async function createUser(displayName: string, email: string): Promise<DocumentReference<UserData> | null> {
  const newUser: UserAddData = { displayName, email }; // Only required fields needed initially
  try {
    const docRef = await usersCollection.add(newUser);
    console.log('User added with ID:', docRef.id);
    return docRef;
  } catch (error) { console.error("Error adding user:", error); return null; }
}
// const newUserRef = await createUser('Alice', 'alice@example.com');

Create or Overwrite (Set) ​

Use set() for documents with specific IDs. Requires UserData (full structure) unless merging.

typescript
async function createOrReplaceUser(userId: string, data: UserData) {
  try {
    await usersCollection.set(userId, data);
    console.log(`Document ${userId} set/replaced.`);
  } catch (error) { console.error(`Error setting document ${userId}:`, error); }
}

const bobData: UserData = {
  id: 'bob-123', // ID is included in the UserData type after fetching, but not stored in the Firestore document itself.
  displayName: 'Bob',
  email: 'bob@example.com',
  isActive: true,
  status: 'active', // Assuming UserStatus enum/union type
  createdAt: Timestamp.now(), // Provide values for fields with defaults if not merging
  updatedAt: Timestamp.now(),
};
// await createOrReplaceUser('bob-123', bobData);

// Set with Merge (Upsert)
async function upsertUserPartial(userId: string, partialData: Partial<UserData>) {
   try {
    await usersCollection.set(userId, partialData, { merge: true });
    console.log(`Document ${userId} upserted (merged).`);
  } catch (error) { console.error(`Error upserting document ${userId}:`, error); }
}
// await upsertUserPartial('charlie-456', { displayName: 'Charlie', age: 25 });

Read (Get) ​

Use get() to retrieve a single document by ID.

typescript
async function getUser(userId: string): Promise<UserData | null> {
  try {
    const userData = await usersCollection.get(userId); // Returns UserData | null
    if (userData) { console.log('User found:', userData.displayName); return userData; }
    else { console.log(`User ${userId} not found.`); return null; }
  } catch (error) { console.error(`Error getting user ${userId}:`, error); return null; }
}
// const user = await getUser('alice-abc');

Update ​

Use update() to get an UpdateBuilder for atomic modifications.

typescript
async function updateUserLogin(userId: string) {
  try {
    await usersCollection.update(userId)
      .incrementLoginCount(1) // Generated method
      .setLastLogin(serverTimestamp()) // Use server timestamp helper
      .commit();
    console.log(`Updated login info for ${userId}.`);
  } catch (error) { console.error(`Error updating user ${userId}:`, error); }
}
// await updateUserLogin('bob-123');

(See Advanced Updates below for more)

Delete ​

Use delete() to remove a document by ID.

typescript
async function deleteUser(userId: string): Promise<boolean> {
  try {
    await usersCollection.delete(userId);
    console.log(`User ${userId} deleted successfully.`);
    return true;
  } catch (error) { console.error(`Error deleting user ${userId}:`, error); return false; }
}
// await deleteUser('charlie-456');

Querying Data ​

Build and execute queries using the QueryBuilder.

(Setup assumed from above)

typescript
import { DocumentSnapshot } from 'firebase/firestore'; // Needed for pagination

Filtering (where[FieldName]) ​

Use generated methods for type-safe filtering.

typescript
const activeUserQuery = usersCollection.query()
  .whereIsActive("==", true)
  .whereAge(">=", 18);

Ordering (orderBy) ​

Sort results. Requires indexes for complex sorts.

typescript
const orderedQuery = usersCollection.query()
  .orderBy("address.city", "asc")
  .orderBy("displayName", "desc");

Limiting (limit, limitToLast) ​

Restrict the number of results.

typescript
const limitQuery = usersCollection.query().orderBy("displayName").limit(10);

Pagination (startAfter, etc.) ​

Use cursor methods with DocumentSnapshot from query.get().

typescript
let lastDoc: DocumentSnapshot<UserData> | null = null;
async function loadMoreUsers() {
  let queryBuilder = usersCollection.query().orderBy("displayName").limit(10);
  if (lastDoc) { queryBuilder = queryBuilder.startAfter(lastDoc); }
  const snapshot = await queryBuilder.get(); // Use get() for snapshot
  if (!snapshot.empty) { lastDoc = snapshot.docs[snapshot.docs.length - 1]; }
  else { lastDoc = null; }
  const users = usersCollection.converter.fromFirestoreQuerySnapshot(snapshot);
  // ... process users ...
}

Executing (getData, get) ​

  • getData(): Returns Promise<UserData[]>.
  • get(): Returns Promise<QuerySnapshot<UserData>> (useful for pagination).
typescript
const users = await activeUserQuery.getData();
const snapshot = await orderedQuery.limit(5).get();

Realtime Updates (Streaming) ​

Use Firebase onSnapshot with references or queries obtained from the generated helpers.

(Setup assumed from above)

typescript
import { onSnapshot, doc, QuerySnapshot, Unsubscribe, FirestoreError } from 'firebase/firestore';

Streaming a Single Document ​

typescript
function listenToUser(userId: string): Unsubscribe {
  const userDocRef = usersCollection.docRef(userId); // Typed DocumentReference
  const unsubscribe = onSnapshot(userDocRef, (snapshot: DocumentSnapshot<UserData>) => {
    if (snapshot.exists()) { const userData = snapshot.data(); /* Update UI */ }
    else { /* Handle deletion */ }
  }, (error: FirestoreError) => { /* Handle error */ });
  return unsubscribe;
}
// const stopListening = listenToUser('user-123');
// stopListening(); // To stop

Streaming Query Results ​

typescript
function listenToActiveUsers(): Unsubscribe {
  const queryBuilder = usersCollection.query().whereIsActive("==", true).orderBy("displayName");
  const unsubscribe = onSnapshot(queryBuilder.query, (snapshot: QuerySnapshot<UserData>) => {
    const activeUsers = usersCollection.converter.fromFirestoreQuerySnapshot(snapshot);
    // Update UI with activeUsers list
    // snapshot.docChanges() provides granular changes
  }, (error: FirestoreError) => { /* Handle error */ });
  return unsubscribe;
}
// const stopActiveUsersListener = listenToActiveUsers();
// stopActiveUsersListener(); // To stop

Working with Subcollections ​

Access subcollections via generated methods on the parent Collection instance.

(Setup assumed from above)

typescript
import { PostsCollection } from '../generated/firestore/users/posts.collection'; // Adjust path
import { PostAddData } from '../generated/firestore/users/posts.types';
typescript
const userId = 'user-123';
const userPostsCollection: PostsCollection = usersCollection.posts(userId); // Get subcollection ref

// Now use userPostsCollection like any other collection
const newPostData: PostAddData = { title: 'Sub Post', content: '...' };
const postRef = await userPostsCollection.add(newPostData);
const post = await userPostsCollection.get(postRef.id);
const posts = await userPostsCollection.query().limit(5).getData();

// Access nested subcollections by chaining
// const commentsCollection = usersCollection.posts(userId).comments(postRef.id);

Advanced Updates ​

Use the UpdateBuilder (from collection.update(id)) for atomic operations.

(Setup assumed from above)

typescript
import { increment, arrayUnion, arrayRemove, deleteField } from 'firebase/firestore';

Generated Helpers ​

typescript
await usersCollection.update(userId)
  .incrementAge(1)
  .setLastLoginToServerTimestamp()
  .commit();

Using Raw FieldValue Operations ​

For nested fields or operations not covered by generated helpers, you can use the standard Firebase updateDoc function with the raw DocumentReference and FieldValue functions directly.

typescript
import { updateDoc, doc } from 'firebase/firestore'; // Import updateDoc and doc

// Get the raw DocumentReference
const userDocRef = usersCollection.docRef(userId);

await updateDoc(userDocRef, {
  'settings.theme': 'dark',                 // Nested field
  'profile.visits': increment(1),         // Nested increment using imported function
  tags: arrayUnion('atomic', 'update'),   // Add array elements using imported function
  oldTags: arrayRemove('legacy'),         // Remove array elements using imported function
  tempData: deleteField()                 // Delete field using imported function
});

Combining Updates ​

You can combine generated helpers with raw updates by committing the builder first, then performing the raw update, or vice-versa if appropriate for your logic. However, they cannot be chained within the same UpdateBuilder instance.

typescript
// Example: Use builder for some fields, then raw update for others
await usersCollection.update(userId)
  .setDisplayName('Updated User')
  .commit(); // Commit builder changes first

await updateDoc(usersCollection.docRef(userId), {
  tags: arrayUnion('combined') // Perform raw update separately
});

Transactions & Batched Writes ​

Use standard Firebase SDK functions (runTransaction, writeBatch) with raw references and converters from the generated helpers.

(Setup assumed from above)

typescript
import { runTransaction, writeBatch, Transaction, WriteBatch } from 'firebase/firestore';
import { StatsCollection } from '../generated/firestore/stats.collection'; // Example

Transactions ​

typescript
await runTransaction(firestore, async (transaction: Transaction) => {
  const userRef = usersCollection.docRef(userId); // Raw ref
  const userSnap = await transaction.get(userRef); // Read
  if (!userSnap.exists()) throw new Error("User not found");
  const userData = userSnap.data(); // Typed data
  // ... perform logic based on userData ...
  transaction.update(userRef, { points: increment(10) }); // Write (using imported increment)
});

Batched Writes ​

typescript
const batch: WriteBatch = writeBatch(firestore);
const userRef = usersCollection.docRef(userId);
const newUserRef = usersCollection.docRef(); // New doc ref
const newUser: UserAddData = { /* ... */ };

// Use converter for set with typed data
batch.set(newUserRef, usersCollection.converter.toFirestore(newUser));
batch.update(userRef, { 'profile.lastAction': serverTimestamp() });
batch.delete(usersCollection.docRef('old-user-id'));

await batch.commit();

(Refer to the official Firebase documentation for more complex transaction/batch patterns)


Testing Strategy ​

  • Unit Tests: Use Jest mocks to test application logic that calls generated ODM methods without hitting Firestore.
  • Integration Tests: Use the Firestore Emulator and Jest. Instantiate generated Collection classes with an emulator-connected Firestore instance to test real interactions (CRUD, queries, updates, etc.). This is the primary method used for testing the @shtse8/fireschema-ts-client-runtime package itself.

Released under the ISC License.