For years, I have wondered why you can’t just pass a type to a Firebase query in the Real Time Database or Cloud Firestore and return the data as that type. Firestore has functions for this, which I wasn’t aware of when it was added to the SDK. Firestore is schemaless, so it does not know your types. You can add any supported Firestore type to any key or supported type, and Firestore will accept it.
TL;DR#
You can use a Firestore Data Converter to handle TypeScript types, return your typed data snapshot, add generated fields and dates, and reuse the function anywhere you call a query or mutation on your collections. However, I’m not too sure a reusable function is not easier to maintain, less code, and easier to understand.
What is a Data Converter#
A data converter is just an object in TypeScript that returns two functions.
toFirestore#
This function modifies the data before you add a document using addDoc
or setDoc
with a merge
option. It does not work on updateDoc
.
fromFirestore#
This function modifies the data you get from the database before returning the data using any query.
// a basic understanding
const dataConverter = {
toFirestore(value: WithFieldValue<someType>) {
return value;
},
fromFirestore(snapshot: QueryDocumentSnapshot {
return snapshot.data();
};
That’s it. Nothing special about it.
Implementing a Data Converter#
To use a data converter, you attach it as an object to the end of your doc
, collection
, or query
.
// document
doc(db, 'posts', 'YMOaDWjMnWbn2MsLYRNv').withConverter(dataConverter);
// collection
collection(db, 'posts').withConverter(dataConverter);
// query
query(
collection(db, 'posts')
).withConverter(dataConverter);
Bare Minimum Example#
Let’s say we want to use correct typing. First, define a type.
type Post = {
title: string;
};
We can ensure our data()
function returns the correct type using the converter.
const dataConverter = {
toFirestore(value: WithFieldValue<Post>) {
return value;
},
fromFirestore(
snapshot: QueryDocumentSnapshot
) {
return snapshot.data() as Post;
}
};
This will ensure when we add or update data with addDoc
or setDoc
, we get the correct result.
// add data
addDoc(
collection(db, 'posts').withConverter(dataConverter),
{ title: 'some title' }
);
// add with setDoc
setDoc(
doc(collection(db, 'posts')).withConverter(dataConverter),
{ title: 'some other title' }
);
// update data
setDoc(
doc(collection(db, 'posts')).withConverter(dataConverter),
{ title: 'updated title' },
{ merge: true }
);
// ⚠️ This will not call toFirestore, but will call fromFirestore
updateDoc(
doc(db, 'posts', 'some-id-here').withConverter(dataConverter),
{ title: 'never updated', }
);
Options Second Parameter#
Both functions have a second parameter, which is used in different cases.
const dataConverter = {
toFirestore(
value: WithFieldValue,
options?: SetOptions
) {
return value;
},
fromFirestore(
snapshot: QueryDocumentSnapshot,
options: SnapshotOptions
) {
return snapshot.data();
}
};
The actual type looks like this:
interface FirestoreDataConverter<
AppModelType,
DbModelType extends DocumentData = DocumentData
> {
toFirestore(
modelObject: WithFieldValue<AppModelType>
): WithFieldValue<DbModelType>;
toFirestore(
modelObject: PartialWithFieldValue<AppModelType>,
options: SetOptions
): PartialWithFieldValue<DbModelType>;
fromFirestore(
snapshot: QueryDocumentSnapshot<DocumentData, DocumentData>,
options?: SnapshotOptions
): AppModelType;
}
SetOptions#
The second parameter to toFirestore
comes in handy when dealing with merging data.
type SetOptions = {
readonly merge?: boolean | undefined;
} | {
readonly mergeFields?: (string | FieldPath)[] | undefined;
}
If you’re merging data, you can pass the data into your toFirestore
function and check if the data is supposed to be merged or not with:
if (options) {
const isMerge = 'merge' in options;
const isMergeField = 'mergeFields' in options;
}
merge
tells it to merge the new data with all fields, while mergeFields
tells Firebase to merge only with the selected fields.
SnapshotOptions#
When dealing with serverTimestamp()
, the second parameter to fromFirestore
is essential.
interface SnapshotOptions {
readonly serverTimestamps?: 'estimate' | 'previous' | 'none';
}
Your client cannot know the server time when you use the server timestamp. You can estimate it based on local time, use the previous value, or use null. In any case, the value will be immediately updated when the server returns the actual timestamp value.
TypeScript Generic Example#
What if you want to pass your type and you don’t care about modifying any data? You can create a generic. You may even want to add the document ID to your result for every possible query.
const dataConverter = {
toFirestore<T>(value: WithFieldValue<T>) {
return value;
},
fromFirestore<T>(
snapshot: QueryDocumentSnapshot,
options: SnapshotOptions
) {
const data = snapshot.data(options);
return {
...data,
id: snapshot.id
} as T;
}
};
You can create a generic with T
that will work almost as expected.
getDoc#
Get a document with the converter.
const post = await getDoc(
doc(db, 'posts', 'some key')
.withConverter<Post>(dataConverter)
);
if (!post.exists()) {
return;
}
const postData = post.data();
getDocs#
Get several documents with the converter.
const posts = await getDocs(
query(
collection(db, 'posts'),
limit(4)
).withConverter<Post>(dataConverter)
);
if (posts.empty) {
return;
}
const data = posts.docs.map(doc => doc.data());
The post data in both examples returns Post
and Post[]
, respectively.
setDoc#
Adding a document requires you to add Partial
to your passed in Generic since you’re not manually adding the ID.
addDoc(
collection(db, 'posts').withConverter<Partial<Post>>(dataConverter),
{
title: 'some title'
}
);
Modifying, on the other hand, requires you to add your merge options.
setDoc(
doc(db, 'posts', 'some-key').withConverter<Post>(dataConverter),
{
title: 'some title'
},
{ merge: true }
);
Modifying Data#
You can use the converter to make automatic changes if you want to modify your data.
Add a user ID#
Let’s say you want to automatically add a user ID, or uid
, to your document and save it as createdBy
automatically.
const dataConverter = {
toFirestore<T>(value: WithFieldValue<T>) {
if (!auth.currentUser) {
throw 'User not logged in!';
}
return {
...value as T,
createdBy: auth.currentUser.uid
};
},
fromFirestore<T>(
snapshot: QueryDocumentSnapshot,
options: SnapshotOptions
) {
...
}
};
Add Timestamps#
What if you wanted to add createdAt
or updatedAt
automatically, depending on whether you’re adding or updating a document? Remember that this still only works with setDoc
and setDoc
with merge options.
const todoConverter = {
toFirestore(
value: PartialWithFieldValue<Todo>,
options?: SetOptions
) {
const isMerge = options && 'merge' in options;
return {
...value,
[isMerge ? 'updatedAt' : 'createdAt']: serverTimestamp()
};
},
fromFirestore(
snapshot: QueryDocumentSnapshot,
options: SnapshotOptions
) {
...
}
};
⚠️ Warning: If you’re using servertimestamp()
, pass in an option for SnapshotOptions
that returns a date, or you will get an error.
const data = doc.data({
serverTimestamps: 'estimate'
});
Transform Data Going In#
You could use this function to add dependent fields like slug
, which would depend on a title
, generate a short_id
field, or perform small transformations. Significant transformations should probably be done from the backend in Cloud Functions.
Transform Data Going Out#
One of the best uses for fromFirestore
is ensuring your date fields are correctly typed. A Firestore Date field does not match up with a JavaScript date field. The Timestamp
type for date fields has several date functions that do this automatically, like toMillis()
and toDate()
.
fromFirestore(
snapshot: QueryDocumentSnapshot
) {
const data = snapshot.data({
serverTimestamps: 'estimate'
});
const createdAt = data.createdAt as Timestamp;
return {
...data,
createdAt: createdAt.toDate()
} as Post;
}
Do I need all this?#
Data converters are useful for large apps, but you could just as easily create a function like this:
export const snapToData = <T>(
snap: DocumentSnapshot<DocumentData, DocumentData>
) => {
if (!snap.exists()) {
return null;
}
const data = snap.data();
return {
...data,
id: snap.id
} as T;
};
And use it with one line.
const data = snapToData<Post>(post);
Or just manually cast your data wherever you want to.
const postData = post.data() as Post;
Ultimately, you decide how much complexity you want to add to your app. Sometimes, you may need to use as
instead.
Repo Example: Todo App
J