@firetype/client v0.2.0
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',
// `sentAt` is readonly for clients so we'll get a TS error if we add it here
}),
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. inwhere()
query ordocumentRef.get()
method).toFirestore.set
: Converts a processed email to a raw one which can be sent to Firestore (e.g. inset()
(without merge) andadd()
methods).toFirestore.setMerge
: Converts a partial processed email to a raw object which can be sent to Firestore insetMerge()
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',
// `sentAt` is readonly for clients so we'll get a TS error if we add it here
}),
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
// Without Firetype
firestore().collection('aNonExistentCollection'); // OK
// With Firetype
Firestore.collection('aNonExistentCollection'); // TS Error
Querying for a document
// Without Firetype
firestore().collection('emails').where('aNonExistentField', '!=', 'a_value_with_incorrect_type').get(); // OK
// With Firetype
Firestore.collection('emails').where('aNonExistentField', '!=', 'a_value_with_incorrect_type').get(); // TS Error
Updating a document
import { FTFieldValue } from '@firetype/client';
// Without Firetype
firestore()
.collection('emails')
.doc('email_id')
.update({
aNonExistentField: '123', // OK
isSaved: new Date(), // OK
lastModifiedAt: [1, 2, 3], // OK
anOptionalField: firestore.FieldValue.delete(), // OK
aReadonlyField: 'some_value', // OK but you'll get an "Insufficient Permissions" error at runtime
});
// With Firetype
Firestore.collection('emails')
.doc('email_id')
.update({
aNonExistentField: '123', // TS Error: Field does not exist.
isSaved: new Date(), // TS Error: Must be a boolean.
lastModifiedAt: [1, 2, 3], // TS Error: Must be Date or firestore.Timestamp or FTFieldValueServerTimestamp
anOptionalField: FTFieldValue.delete(), // OK
aReadonlyField: 'some_value', // TS Error: Field is read-only for clients
});
API Reference
You can find the full API reference for @firetype/client
here.
License
This project is made available under the MIT License.