Skip to content

Express

Wire the repository into Express.js route handlers with schema-driven validation, and map the ORM’s typed errors to HTTP responses with the bundled errorHandler middleware.

The ORM is framework-agnostic: a repository is just an object you construct once and share. For the error classes themselves, see Error Handling; for the schema strategy, see Schema Validation; for NestJS, see NestJS.

Construct the repository once and export it. Your schema must not declare a top-level id — it is rejected at construction, and the document name is the sole source of id (see Document Identity). The returned repository is fully typed and validated on every write.

repositories/user.repository.ts
import { FirestoreRepository } from 'flintfire';
import { db } from '../config/firebase';
import { userSchema, User } from '../schemas/user.schema';
export const userRepo = FirestoreRepository.withSchema(db, 'users', userSchema);

Define your routes as thin handlers that call the repository and forward any thrown error to the shared errorHandler middleware via next(error):

routes/user.routes.ts
import express from 'express';
import { userRepo } from '../repositories/user.repository';
import { ValidationError, NotFoundError } from 'flintfire';
const router = express.Router();
router.post('/users', async (req, res, next) => {
try {
const user = await userRepo.create(
{
...req.body,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{ returnDoc: true },
);
res.status(201).json(user);
} catch (error) {
next(error); // errorHandler middleware will process this
}
});
router.get('/users', async (req, res, next) => {
try {
const { page = 1, limit = 20, status } = req.query;
let query = userRepo.query();
if (status) {
query = query.where('status', '==', status);
}
const result = await query
.orderBy('createdAt', 'desc')
.offsetPaginate(Number(page), Number(limit));
res.json(result);
} catch (error) {
next(error);
}
});
router.get('/users/:id', async (req, res, next) => {
try {
const user = await userRepo.getById(userRepo.id(req.params.id));
if (!user) {
throw new NotFoundError(`User with id ${req.params.id} not found`);
}
res.json(user);
} catch (error) {
next(error);
}
});
router.patch('/users/:id', async (req, res, next) => {
try {
const user = await userRepo.update(
userRepo.id(req.params.id),
{
...req.body,
updatedAt: new Date().toISOString(),
},
{ returnDoc: true },
);
res.json(user);
} catch (error) {
next(error);
}
});
router.delete('/users/:id', async (req, res, next) => {
try {
await userRepo.delete(userRepo.id(req.params.id));
res.status(204).send();
} catch (error) {
next(error);
}
});
export default router;

Register the routes and mount errorHandler last so it can translate ORM errors into HTTP responses:

app.ts
import express from 'express';
import { errorHandler } from 'flintfire/express';
import userRoutes from './routes/user.routes';
const app = express();
app.use(express.json());
app.use('/api', userRoutes);
app.use(errorHandler); // Must be last
app.listen(3000, () => {
console.log('Server running on port 3000');
});

Notes on the API used above:

  • Request-supplied ids are validated with userRepo.id(req.params.id) before touching Firestore — a malformed id throws InvalidDocumentIdError (→ 400) rather than escaping the collection. See Document Identity.
  • Reads use getById(id), which returns FirestoreDocument<User> | null — check for null (or use getByIdOrThrow(id) to get a NotFoundError instead).
  • Offset pagination is offsetPaginate(page, pageSize). Forward opaque cursor pagination is paginate(pageSize, cursor?) and requires a prior orderBy(). Typed bounds (startAt / startAfter / …) and reverse pages (limitToLast) are also on the query builder — see Queries.
  • update(id, data, { returnDoc: true }) returns the updated document. A top-level id is always stripped from write payloads, so ...req.body cannot repoint the document.

The ORM includes a pre-built Express middleware for consistent error responses. It is published from the optional flintfire/express subpath (not the package root), so express stays out of the core type graph — install express to use it. Register it as the last middleware, after all routes, and call next(error) from your route handlers (or throw and let an async wrapper forward it).

It maps errors to HTTP status codes:

  • ValidationError → 400 Bad Request
  • InvalidDocumentIdError → 400 Bad Request (a malformed caller-supplied id; the body carries the machine-readable reason, never the raw id)
  • InvalidPaginationCursorError → 400 Bad Request (the body carries the machine-readable reason, never the cursor token or decoded document path)
  • NotFoundError → 404 Not Found
  • ConflictError → 409 Conflict (including create-only collisions from createWithId)
  • PreconditionFailedError → 412 Precondition Failed (a failed lastUpdateTime write)
  • FirestoreIndexError → 503 Service Unavailable (a missing index is a server/config failure; the index-creation URL is deliberately not returned to the client — it stays server-side on the caught error’s indexUrl for logging)
  • Others → 500 Internal Server Error

errorHandler(err: any, req: Request, res: Response, next: NextFunction): void

Maps errors to HTTP status codes and JSON bodies:

Error Status Response body
ValidationError 400 { error: 'ValidationError', details: issues }
InvalidDocumentIdError 400 { error: 'InvalidDocumentIdError', reason }
InvalidPaginationCursorError 400 { error: 'InvalidPaginationCursorError', reason }
NotFoundError 404 { error: 'NotFoundError', message }
FirestoreIndexError 503 { error: 'Query needs an index', message }
ConflictError 409 { error: 'ConflictError', message }
PreconditionFailedError 412 { error: 'PreconditionFailedError', message }
WriteOutcomeError 500 { error: 'WriteOutcomeError', outcome } (cause is server-side only)
Anything else 500 { error: 'InternalServerError', message: 'Something went wrong' }

The generic 500 branch intentionally hides the underlying message so internal details are not leaked to clients. WriteOutcomeError exposes only the safe discriminated outcome — never cause, stack, or cause message.

{
"error": "WriteOutcomeError",
"outcome": {
"state": "committed",
"phase": "after-hook",
"hook": {
"event": "afterCreate",
"execution": "direct",
"retryable": false
}
}
}