Firetype Client
Firetype is a lightweight TypeScript library that lets you strictly type your Firebase architecture.
Firetype Client wraps the Firebase JS SDK in a way that lets you provide a set of types describing your data so you can prevent bugs, errors and crashes at compile time. It remembers the "schema" that you feed to it and stops you from writing bad code. This, combined with the ease with which it lets you bring changes to your codebase, provides a great developer experience.
Installation
If you haven't already installed firebase
, run
npm install firebase
Then,
npm install @firetype/client
Quick Start
Consider a simple emails
collection that needs to be typed. The first thing we need to do is to create an interface that describes an email doc in this collection. Create a new file (perhaps services/firestore.ts
) where you'll add all this information.
import type { firestore } from 'firebase';
interface EmailDoc {
from: string;
to: string | string[];
subject?: string;
sentAt: firestore.Timestamp;
}
This represents the shape of an arbitrary email doc exactly how it is stored in Firestore. The subject
field is optional, i.e. it may be missing from the Firestore document. Note that a field with a null
value is different from a "missing" field.
To use email objects inside our application, we probably want to process them so need to have a model (class, interface etc.) that we'll use throughout the app. You can keep using EmailDoc
if you think you don't need such a model.
class Email {
constructor(public readonly id: string, private readonly raw: EmailDoc) {}
get from() {
return this.raw.from;
}
get to() {
return this.raw.to;
}
get subject() {
return this.raw.subject;
}
get sentAt() {
return this.raw.sentAt.toDate();
}
describe() {
return `Email sent by ${this.from} at ${this.sentAt.toUTCString()}.`;
}
}
We combine these two models into an interface which needs to extend FTCollectionModel
interface EmailsCollectionModel {
model: {
raw: EmailDoc;
processed: Email;
};
readonlyFields: {
sentAt: true;
};
}
We also add an interface describing our entire Firestore model, which needs to extend FTFirestoreModel
interface FirestoreModel {
emails: EmailsCollectionModel;
}
The key that you choose (in our case emails
) will be the Firestore key associated with the collection.
We now have one final task, which is to create our Firestore describer that consists of one collection describer. A collection describer is an object that contains details such as model converters and subcollection describers (see FTCollectionDescriber
)
import type { FTFirestoreDescriber } from '@firetype/client';
const describer: FTFirestoreDescriber<FirestoreModel> = {
emails: {
converter: {
fromFirestore: snap => {
return new Email(snap.id, snap.data());
},
toFirestore: {
set: email => ({
from: email.from,
to: email.to,
subject: email.subject ?? 'Some Topic',
}),
setMerge: email =>
removeUndefinedFields({
from: email.from,
to: email.to,
subject: email.subject,
}),
},
},
},
};
Our describer has 3 converters:
fromFirestore
: Processes a raw email that we get from Firestore (e.g. in where()
query or documentRef.get()
method).toFirestore.set
: Converts a processed email to a raw one which can be sent to Firestore (e.g. in set()
(without merge) and add()
methods).toFirestore.setMerge
: Converts a partial processed email to a raw object which can be sent to Firestore in setMerge()
method.
The describer needs to be created only once and cached for later use. You don't need to interact with it. You just pass it to Firetype and get an FTFirestore
instance which you'll use throughout your application.
import { FTFirestore } from '@firetype/client';
export const Firestore = new FTFirestore<FirestoreModel>(describer);
Putting everything together we have the following
import type { firestore } from 'firebase';
import { FTFirestore, FTFirestoreDescriber } from '@firetype/client';
interface EmailDoc {
from: string;
to: string | string[];
subject?: string;
sentAt: firestore.Timestamp;
}
class Email {
constructor(public readonly id: string, private readonly raw: EmailDoc) {}
get from() {
return this.raw.from;
}
get to() {
return this.raw.to;
}
get subject() {
return this.raw.subject;
}
get sentAt() {
return this.raw.sentAt.toDate();
}
describe() {
return `Email sent by ${this.from} at ${this.sentAt.toUTCString()}.`;
}
}
interface EmailsCollectionModel {
model: {
raw: EmailDoc;
processed: Email;
};
readonlyFields: {
sentAt: true;
};
}
interface FirestoreModel {
emails: EmailsCollectionModel;
}
const describer: FTFirestoreDescriber<FirestoreModel> = {
emails: {
converter: {
fromFirestore: snap => {
return new Email(snap.id, snap.data());
},
toFirestore: {
set: email => ({
from: email.from,
to: email.to,
subject: email.subject ?? 'Some Topic',
}),
setMerge: email =>
removeUndefinedFields({
from: email.from,
to: email.to,
subject: email.subject,
}),
},
},
},
};
export const Firestore = new FTFirestore<FirestoreModel>(describer);
That's it! You can now use your well-typed FTFirestore
instance to securely edit your Firestore documents and collections. While Firetype lets you strictly type your architecture, it also provides an easy way to escape strict typing. Every Firetype object that you'll be interacting with has a .core
property which gives you access to the underlying Firebase object which is loosely typed. This is particularly useful if you're planning to migrate to Firetype over a period of time.
Examples
Below are a several examples that show the difference between using Firetype and raw Firebase objects.
Accessing a collection
firestore().collection('aNonExistentCollection');
Firestore.collection('aNonExistentCollection');
Querying for a document
firestore().collection('emails').where('aNonExistentField', '!=', 'a_value_with_incorrect_type').get();
Firestore.collection('emails').where('aNonExistentField', '!=', 'a_value_with_incorrect_type').get();
Updating a document
import { FTFieldValue } from '@firetype/client';
firestore()
.collection('emails')
.doc('email_id')
.update({
aNonExistentField: '123',
isSaved: new Date(),
lastModifiedAt: [1, 2, 3],
anOptionalField: firestore.FieldValue.delete(),
aReadonlyField: 'some_value',
});
Firestore.collection('emails')
.doc('email_id')
.update({
aNonExistentField: '123',
isSaved: new Date(),
lastModifiedAt: [1, 2, 3],
anOptionalField: FTFieldValue.delete(),
aReadonlyField: 'some_value',
});
You can find the full API reference for @firetype/client
here.
License
This project is made available under the MIT License.