Strongly typed Firestore framework for TypeScript
Features
- Strong type safety for Firestore - Automatically provide type information to nested documents without unsafe type assertions, from the simple schema. Also support data decoding.
- Security rules generation - Generate firestore.rules file including data type validation and access control from the schema.
- React Hooks - Get realtime updates with React Hooks.
- Type safety for Cloud Functions
- Automatically provide type information to snapshot data on Firestore Trigger Function based on the path string.
- Guard HTTPS callable function's request/response data type both on compile time and runtime.
Requirement
- TypeScript (>= 4.4)
Install
yarn add fireschema firebase firebase-admin firebase-functions zod
yarn add -D typescript ts-node
Setup
🎉 Since Fireschema v5, you no longer need to compile codes via custom transformer.
Usage - Firestore
Schema Transformation
Zod Schema | Security Rules Output |
---|---|
z.any() |
true |
z.unknown() |
true |
z.undefined() |
!("key" in data) |
z.null() |
data.key == null |
z.boolean() |
data.key is bool |
z.literal('a') |
data.key == "a" |
z.string() |
data.key is string |
z.string().min(5) |
(data.key is string && data.key.size >= 5) |
z.string().min(5).max(20) |
(data.key is string && data.key.size >= 5 && data.key.size <= 20) |
z.string().regex(/@example\.com$/) |
(data.key is string && data.key.matches("@example\\.com$")) |
z.number() |
data.key is number |
z.number().int() |
data.key is int |
z.number().min(5) |
(data.key is int && data.key >= 5) |
z.number().max(20) |
(data.key is int && data.key <= 20) |
timestampType() |
data.key is timestamp |
z.record(z.string()) |
data.key is map |
z.tuple([z.string(), z.number()]) |
(data.key is list && data.key[0] is string && data.key[1] is number) |
z.string().array() |
data.key is list |
z.string().array().min(5) |
(data.key is list && data.key.size() >= 5) |
z.string().array().max(20) |
(data.key is list && data.key.size() <= 20) |
z.string().optional() |
(data.key is string || !("key" in data)) |
z.union([z.string(), z.null()]) |
(data.key is string || data.key == null) |
1. Define schema
The schema definition must be default exported.
import { Merge } from 'type-fest'
import { z } from 'zod'
import { DataModel, FirestoreModel, rules, timestampType } from 'fireschema'
export const UserType = z.object({
name: z.string(),
displayName: z.union([z.string(), z.null()]),
age: z.number().int(),
timestamp: timestampType(),
options: z.object({ a: z.boolean() }).optional(),
})
type User = z.infer<typeof UserType>
/* => {
name: string
displayName: string | null
age: number
timestamp: FTypes.Timestamp
options?: { a: boolean } | undefined
} */
type UserDecoded = Merge<User, { timestamp: Date }>
const UserModel = new DataModel({
schema: UserType,
decoder: (data: User): UserDecoded => ({
...data,
timestamp: data.timestamp.toDate(),
}),
})
const PostType = z.object({
authorUid: z.string(),
text: z.string(),
tags: z.object({ id: z.number().int(), name: z.string() }).array(),
})
const PostModel = new DataModel({
schema: PostType,
selectors: (q) => ({
byTag: (tag: string) => [
q.where('tags', 'array-contains', tag),
q.limit(20),
],
}),
})
export const firestoreModel = new FirestoreModel({
'function isAdmin()': `
return exists(${rules.basePath}/admins/$(request.auth.uid));
`,
'function requestUserIs(uid)': `
return request.auth.uid == uid;
`,
collectionGroups: {
'/posts/{postId}': {
allow: {
read: true,
},
},
},
'/users/{uid}': {
model: UserModel,
allow: {
read: true, // open access
write: rules.or('requestUserIs(uid)', 'isAdmin()'),
},
'/posts/{postId}': {
'function authorUidMatches()': `
return request.resource.data.authorUid == uid;
`,
model: PostModel,
allow: {
read: true,
write: rules.and('requestUserIs(uid)', 'authorUidMatches()'),
},
},
},
})
export default firestoreModel
Write rules are combined with the rules automatically generated from zod schema.
2. Generate firestore.rules
yarn fireschema rules <path-to-schema>.ts
Environment variable
TS_NODE_PROJECT
is supported.
Example of generated firestore.rules
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function __validator_meta__(data) {
return (
(request.method == "create" && data._createdAt == request.time && data._updatedAt == request.time)
|| (request.method == "update" && data._createdAt == resource.data._createdAt && data._updatedAt == request.time)
);
}
function __validator_keys__(data, keys) {
return data.keys().removeAll(['_createdAt', '_updatedAt']).hasOnly(keys);
}
function isAdmin() {
return exists(/databases/$(database)/documents/admins/$(request.auth.uid));
}
function requestUserIs(uid) {
return request.auth.uid == uid;
}
match /{path=**}/posts/{postId} {
allow read: if true;
}
match /users/{uid} {
function __validator_0__(data) {
return (__validator_meta__(data) && (
__validator_keys__(data, ['name', 'displayName', 'age', 'timestamp', 'options'])
&& data.name is string
&& (data.displayName is string || data.displayName == null)
&& data.age is int
&& data.timestamp is timestamp
&& (data.options.a is bool || !("options" in data))
));
}
allow read: if true;
allow write: if ((requestUserIs(uid) || isAdmin()) && __validator_0__(request.resource.data));
match /posts/{postId} {
function authorUidMatches() {
return request.resource.data.authorUid == uid;
}
function __validator_1__(data) {
return (__validator_meta__(data) && (
__validator_keys__(data, ['authorUid', 'text', 'tags'])
&& data.authorUid is string
&& data.text is string
&& data.tags is list
));
}
allow read: if true;
allow write: if ((requestUserIs(uid) && authorUidMatches()) && __validator_1__(request.resource.data));
}
}
}
}
3. Read/write collections and documents
The Firestore interface of Fireschema supports both Web SDK and Admin SDK.
import { initializeApp } from 'firebase/app' // or firebase-admin
import { initializeFirestore } from 'firebase/firestore'
import { TypedFirestoreWeb } from 'fireschema'
import { firestoreModel } from './1-1-schema.js'
const app = initializeApp({
// ...
})
const firestoreApp = initializeFirestore(app, {
ignoreUndefinedProperties: true,
})
/**
* Initialize TypedFirestore
*/
export const $web: TypedFirestoreWeb<typeof firestoreModel> =
new TypedFirestoreWeb(firestoreModel, firestoreApp)
/**
* Reference collections/documents and get snapshot
*/
const usersRef = $web.collection('users') // TypedCollectionRef instance
const userRef = usersRef.doc('userId') // TypedDocumentRef instance
const postsRef = userRef.collection('posts')
const postRef = postsRef.doc('123')
const techPostsQuery = postsRef.select.byTag('tech') // selector defined in schema
await userRef.get() // TypedDocumentSnap<User>
await userRef.getData() // User | undefined
await userRef.getDataOrThrow() // User
await postRef.get() // TypedDocumentSnap<PostA | PostB>
await postsRef.get() // TypedQuerySnap<PostA | PostB>
await postsRef.getData() // (PostA | PostB)[]
await techPostsQuery.get() // TypedQuerySnap<PostA | PostB>
/**
* Get child collection of retrived document snapshot
*/
const snap = await usersRef.get()
const firstUserRef = snap.docs[0]!.ref
await firstUserRef.collection('posts').get()
/**
* Reference parent collection/document
*/
const _postsRef = postRef.parentCollection()
const _userRef = postsRef.parentDocument()
/**
* Reference collections groups and get snapshot
*/
const postsGroup = $web.collectionGroup('posts')
const techPostsGroup = postsGroup.select.byTag('tech')
await postsGroup.get() // TypedQuerySnap<PostA | PostB>
await techPostsGroup.get() // TypedQuerySnap<PostA | PostB>
/**
* Write data
*/
await userRef.create(({ serverTimestamp }) => ({
name: 'test',
displayName: 'Test',
age: 20,
timestamp: serverTimestamp(),
options: { a: true },
}))
await userRef.setMerge({
age: 21,
})
await userRef.update({
age: 21,
})
await userRef.delete()
/**
* Transaction
*/
await $web.runTransaction(async (tt) => {
const snap = await tt.get(userRef)
tt.update(userRef, {
age: snap.data()!.age + 1,
})
})
Write methods of Fireschema's document reference
create()
- Create a document. (_createdAt
/_updatedAt
fields are added)- Web - Call JS SDK's
set()
internally. It fails if the document already exists because overwriting _createdAt is denied by the automatically generated security rules. - Admin - Call Admin SDK's
create()
internally. It fails if the document already exists.
- Web - Call JS SDK's
setMerge()
- Callset(data, { merge: true })
internally. (_updatedAt
field is updated)update()
- Callupdate()
internally. (_updatedAt
field is updated)
set()
is not implemented on fireschema because it cannot determine whether_createdAt
should be included in update fields without specifying it is a new creation or an overwrite.
4. React Hooks
import React, { Suspense } from 'react'
import { useTypedCollection, useTypedDoc } from 'fireschema/hooks'
import { $web } from './1-3-typed-firestore.js'
/**
* Get realtime updates of collection/query
*/
export const PostsComponent = () => {
const userRef = $web.collection('users').doc('user1')
const postsRef = userRef.collection('posts')
const posts = useTypedCollection(postsRef)
const techPosts = useTypedCollection(postsRef.select.byTag('tech'))
return (
<Suspense fallback={'Loading...'}>
<ul>
{posts.data.map((post, i) => (
<li key={i}>{post.text}</li>
))}
</ul>
</Suspense>
)
}
/**
* Get realtime updates of document
*/
export const UserComponent = ({ id }: { id: string }) => {
const user = useTypedDoc($web.collection('users').doc(id))
return (
<Suspense fallback={'Loading...'}>
<span>{user.data?.displayName}</span>
</Suspense>
)
}
Usage - Cloud Functions
1. Create functions
import * as functions from 'firebase-functions'
import { z } from 'zod'
import { TypedFunctions } from 'fireschema/admin'
import { UserType, firestoreModel } from './1-1-schema.js'
/**
* Initialize TypedFunctions
*/
const timezone = 'Asia/Tokyo'
const typedFunctions = new TypedFunctions(firestoreModel, timezone)
const builder = functions.region('asia-northeast1')
/**
* functions/index.ts file
*/
export const UserJsonType = UserType.extend({ timestamp: z.string() })
export const callable = {
createUser: typedFunctions.callable({
schema: {
input: UserJsonType, // schema of request data (automatically validate on request)
output: z.object({ result: z.boolean() }), // schema of response data
},
builder,
handler: async (data, context) => {
console.log(data) // UserJson
return { result: true }
},
}),
}
export const firestoreTrigger = {
onUserCreate: typedFunctions.firestoreTrigger.onCreate({
builder,
path: 'users/{uid}',
handler: async (decodedData, snap, context) => {
console.log(decodedData) // UserDecoded (provided based on path string)
console.log(snap) // QueryDocumentSnapshot<User>
},
}),
}
export const http = {
getKeys: typedFunctions.http({
builder,
handler: (req, resp) => {
if (req.method !== 'POST') {
resp.status(400).send()
return
}
resp.json(Object.keys(req.body))
},
}),
}
export const topic = {
publishMessage: typedFunctions.topic('publish_message', {
schema: z.object({ text: z.string() }),
builder,
handler: async (data) => {
data // { text: string }
},
}),
}
export const schedule = {
cron: typedFunctions.schedule({
builder,
schedule: '0 0 * * *',
handler: async (context) => {
console.log(context.timestamp)
},
}),
}
2. Call HTTPS callable function
Automatically provide types to request/response data based on passed functions module type.
import { initializeApp } from 'firebase/app'
import { getFunctions } from 'firebase/functions'
import React from 'react'
import { TypedCaller } from 'fireschema'
type FunctionsModule = typeof import('./2-1-typed-functions.js')
const app = initializeApp({
// ...
})
const functionsApp = getFunctions(app, 'asia-northeast1')
export const typedCaller = new TypedCaller<FunctionsModule>(functionsApp)
const Component = () => {
const createUser = async () => {
const result = await typedCaller.call('createUser', {
name: 'test',
displayName: 'Test',
age: 20,
timestamp: new Date().toISOString(),
options: { a: true },
})
if (result.error) {
console.error(result.error)
return
}
console.log(result.data)
}
return <button onClick={createUser} />
}