Meteor Typed Model
A Zod validated, type-safe wrapper around Meteor Mongo Collections with automatic runtime validation and full TypeScript type inference.
Features
- Type-Safe: Full TypeScript type inference for queries, inserts, and updates
- Runtime Validation: Automatic Zod schema validation on all operations
- Zero Boilerplate: Auto-populated timestamps, user tracking, and IDs
- Smart Updates: MongoDB update operators with schema validation
- Field Projections: Return types automatically narrow based on field selection
- Client Security: Automatic validation deny rules with Meteor's allow/deny system
- Protected Fields:
denyUntrusted()helper prevents privilege escalation attacks - Database Validation: Opt-in
attachValidatorgenerates MongoDB JSON Schema validators from your Zod schema - Custom Types: Pre-built Zod types for common patterns
- Schema Helpers: Easy composition with
withTimestamps,withUsers, andwithCommon - Production Ready: Extracted from JollyRoger with 143 tests (111 server + 32 client)
Installation
Install typed:model and zod:
meteor add typed:model meteor npm install zod@^3.23.0
The 1.x line targets Zod 3 (
^3.23.0). For Zod 4, usetyped:model@2.x.
Quick Start
1import { Model, CustomTypes } from 'meteor/typed:model'; 2import { z } from 'zod'; 3 4const { nonEmptyString } = CustomTypes; 5 6// Define your schema 7const TaskSchema = z.object({ 8 title: nonEmptyString, 9 completed: z.boolean().default(false), 10}); 11 12// Create a model 13const TaskModel = new Model({ 14 name: 'tasks', 15 schema: TaskSchema, 16}); 17 18// Use it! 19const taskId = await TaskModel.insertAsync({ 20 title: 'Learn typed:model', 21 completed: false, 22}); 23 24const task = await TaskModel.findOneAsync(taskId); 25console.log(task.title); // Full type safety!
Getting Started
Step 1: Define Your Schema
Start by defining a Zod schema for your documents:
1import { z } from 'zod'; 2import { CustomTypes, SchemaHelpers } from 'meteor/typed:model'; 3 4const { nonEmptyString } = CustomTypes; 5const { withCommon } = SchemaHelpers; 6 7// Basic schema 8const LinkSchema = z.object({ 9 title: nonEmptyString, 10 url: z.string().url(), 11}); 12 13// With automatic timestamps and user tracking 14const LinkSchemaWithMeta = withCommon(LinkSchema); 15// Adds: createdAt, updatedAt, createdBy, updatedBy
Step 2: Create a Model
Create a Model instance to wrap your collection:
1import { Model } from 'meteor/typed:model'; 2import type { ModelType } from 'meteor/typed:model'; 3 4export const LinkModel = new Model({ 5 name: 'links', 6 schema: LinkSchemaWithMeta, 7}); 8 9// Export the inferred type for use throughout your app 10export type LinkType = ModelType<typeof LinkModel>;
Step 3: Use Your Model
Use the Model's async methods with full type safety:
1// Insert (returns the _id) 2const linkId = await LinkModel.insertAsync({ 3 title: 'Meteor Docs', 4 url: 'https://docs.meteor.com', 5}); 6 7// Find one 8const link = await LinkModel.findOneAsync(linkId); 9console.log(link.title); // TypeScript knows all fields! 10 11// Update with MongoDB operators 12await LinkModel.updateAsync(linkId, { 13 $set: { title: 'Updated Title' }, 14}); 15 16// Find with field projection (return type is automatically narrowed!) 17const titleOnly = await LinkModel.findOneAsync( 18 { _id: linkId }, 19 { fields: { title: 1 } } 20); 21// titleOnly has type: { _id: string, title: string } 22 23// Query with cursor 24const allLinks = LinkModel.find({}).fetch(); 25 26// Remove 27await LinkModel.removeAsync(linkId);
Step 4: Set Up Client Security
Define allow/deny rules for client-side operations:
1// Allow users to insert their own links 2LinkModel.allow({ 3 insert: (userId, doc) => { 4 return userId !== null && doc.createdBy === userId; 5 }, 6 update: (userId, doc) => { 7 return userId !== null && doc.createdBy === userId; 8 }, 9 remove: (userId, doc) => { 10 return userId !== null && doc.createdBy === userId; 11 }, 12});
See Client-Side Security below for more details.
Documentation
- API Reference - Complete API documentation for Model class and methods
- Custom Types - Pre-built Zod types for common patterns
- Schema Helpers - Composition helpers like
withCommon,withTimestamps,withUsers - Type System - TypeScript type inference and type utilities
- Advanced Features - Schema relaxation, MongoDB operators, indexes
- Migration Guide - Migrating from vanilla collections or collection2
- Troubleshooting - FAQ and common issues
- Best Practices - Performance, security, and design patterns
Basic Usage Examples
Working With Existing Collections
You can wrap existing Meteor collections like Meteor.users:
1import { Model, CustomTypes } from 'meteor/typed:model'; 2import { z } from 'zod'; 3 4const { nonEmptyString } = CustomTypes; 5 6const UserSchema = z.object({ 7 // Note: use nonEmptyString rather than z.string() - string fields must not 8 // accept empty strings (see Important Constraints) 9 username: nonEmptyString.optional(), 10 emails: z.array(z.object({ 11 address: z.string().email(), 12 verified: z.boolean(), 13 })).optional(), 14 createdAt: z.date().optional(), 15 profile: z.record(z.string(), z.unknown()).optional(), 16 services: z.record(z.string(), z.unknown()).optional(), 17 // ... extend to match your user structure 18}); 19 20const UserModel = new Model({ 21 name: 'users', 22 schema: UserSchema, 23 collection: Meteor.users, 24}); 25 26const user = await UserModel.findOneAsync(Meteor.userId()!);
Type Extraction
Extract TypeScript types from your models:
1import type { ModelType } from 'meteor/typed:model'; 2 3export const TaskModel = new Model({ 4 name: 'tasks', 5 schema: TaskSchema, 6}); 7 8// Extract the document type 9export type TaskType = ModelType<typeof TaskModel>; 10 11// Use in functions 12function processTask(task: TaskType) { 13 console.log(task.title); 14}
MongoDB Update Operators
All MongoDB update operators are validated against your schema:
1await TaskModel.updateAsync(taskId, { 2 $set: { title: 'New Title' }, 3 $inc: { priority: 1 }, 4 $push: { tags: 'urgent' }, 5 $addToSet: { watchers: userId }, 6 $unset: { dueDate: '' }, 7});
Client-Side Security with Allow/Deny Rules
The typed:model package provides automatic schema validation for client-side database operations using Meteor's allow/deny system, similar to the collection2 package. This ensures that data validation happens transparently on both the client and server.
Automatic Schema Validation
When you create a Model, validation deny rules are automatically applied to the underlying Mongo.Collection. These rules:
- Run only for client-initiated operations (server-side code is trusted and bypasses these rules)
- Validate all documents against your Zod schema before they reach the database
- Work even with direct collection access (e.g.,
model.collection.insertAsync()) - Format Zod errors as Meteor errors for consistent error handling
This means validation happens automatically without any additional setup, but you still need to define allow rules to permit client-side operations.
Setting Allow Rules
Allow rules determine which client-side operations are permitted. At least one allow rule must return true for an operation to succeed:
1import { Model, CustomTypes } from 'meteor/typed:model'; 2import { z } from 'zod'; 3 4const { nonEmptyString } = CustomTypes; 5 6const PostSchema = z.object({ 7 title: nonEmptyString, 8 content: nonEmptyString, 9 authorId: z.string(), 10 published: z.boolean().default(false), 11}); 12 13const PostModel = new Model({ 14 name: 'posts', 15 schema: PostSchema, 16}); 17 18// Allow users to insert their own posts 19PostModel.allow({ 20 insert: (userId, doc) => { 21 // Only allow if user is logged in and is the author 22 return userId !== null && doc.authorId === userId; 23 }, 24 update: (userId, doc, fieldNames, modifier) => { 25 // Only allow authors to update their own posts 26 return userId !== null && doc.authorId === userId; 27 }, 28 remove: (userId, doc) => { 29 // Only allow authors to remove their own posts 30 return userId !== null && doc.authorId === userId; 31 }, 32});
Setting Deny Rules
Deny rules block operations even if allow rules would permit them. If any deny rule returns true, the operation is rejected:
1// Prevent updates to published posts 2PostModel.deny({ 3 update: (userId, doc) => { 4 // Deny updates to published posts 5 return doc.published === true; 6 }, 7 remove: (userId, doc) => { 8 // Never allow removing published posts 9 return doc.published === true; 10 }, 11}); 12 13// Prevent changes to the authorId field 14PostModel.deny({ 15 update: (userId, doc, fieldNames) => { 16 // Deny if trying to modify authorId 17 return fieldNames.includes('authorId'); 18 }, 19});
Protecting Sensitive Fields with denyUntrusted
For fields that should never be modified by client code (like isAdmin, role, or system metadata), use the denyUntrusted() helper instead of writing custom deny rules:
1import { CustomTypes } from 'meteor/typed:model'; 2const { denyUntrusted, nonEmptyString } = CustomTypes; 3 4const UserSchema = z.object({ 5 username: nonEmptyString, 6 email: nonEmptyString, 7 8 // These fields are automatically protected from ALL client modifications 9 isAdmin: denyUntrusted(z.boolean().default(false)), 10 role: denyUntrusted(z.enum(['user', 'moderator', 'admin']).default('user')), 11 permissions: denyUntrusted(z.array(nonEmptyString).default([])), 12}); 13 14const UserModel = new Model({ name: 'users', schema: UserSchema }); 15 16// CLIENT: ❌ This will be denied 17try { 18 await UserModel.collection.insertAsync({ 19 username: 'hacker', 20 email: 'hacker@example.com', 21 isAdmin: true, // Attempt to escalate privileges - DENIED! 22 }); 23} catch (error) { 24 // Meteor.Error: "Cannot modify protected field 'isAdmin' from client code" 25} 26 27// CLIENT: ✅ This succeeds (protected field omitted) 28await UserModel.collection.insertAsync({ 29 username: 'user', 30 email: 'user@example.com', 31 // isAdmin omitted - will use default (false) 32}); 33 34// SERVER: ✅ Server can set protected fields freely 35if (Meteor.isServer) { 36 await UserModel.insertAsync({ 37 username: 'admin', 38 email: 'admin@example.com', 39 isAdmin: true, // Allowed on server 40 role: 'admin', 41 }); 42}
Benefits of denyUntrusted:
- Schema-level protection: Defined where your data structure is defined
- Works everywhere: Protects even if collection is accessed directly
- Defense in depth: Uses Meteor's
deny()system under the hood - Auto-protected helpers:
withCommon,withTimestamps, andwithUsersautomatically protect their fields
See Custom Types - denyUntrusted for detailed documentation and examples.
Rule Evaluation Order
Meteor evaluates security rules in a specific order:
- Deny rules are checked first - if any return
true, the operation is rejected - Allow rules are checked next - if any return
true, the operation is permitted - If no rules are defined, or no allow rules return
true, the operation is denied
This means deny rules take precedence over allow rules, allowing you to create exceptions to your allow rules.
Server-Only Operations
The bypassSchema option allows server-side code to bypass validation entirely. This option is only available on the server - client attempts to use it will throw an error:
1// Server-side only 2if (Meteor.isServer) { 3 // Bypass validation for data migration 4 await PostModel.insertAsync( 5 { title: '', content: 'legacy', authorId: 'system' }, 6 { bypassSchema: true } 7 ); 8} 9 10// Client-side - will throw "bypassSchema option is only available on the server" 11await PostModel.insertAsync( 12 { title: '', content: 'test', authorId: userId }, 13 { bypassSchema: true } // Error! 14);
Direct Collection Access
The collection property on Model instances is public and provides direct access to the underlying Mongo.Collection. However, allow/deny rules still apply regardless of how you access the collection:
1// Both of these enforce the same allow/deny rules and schema validation: 2await PostModel.insertAsync({ title: 'Test', content: 'Content', authorId: userId }); 3await PostModel.collection.insertAsync({ title: 'Test', content: 'Content', authorId: userId });
This means you can safely use the underlying collection methods when needed without bypassing security.
Database-Level Validation
Allow/deny rules only cover writes that go through Meteor. To also enforce your schema inside MongoDB itself, opt in with attachValidator: true:
1const PostModel = new Model({ 2 name: 'posts', 3 schema: PostSchema, 4 attachValidator: true, 5});
The Zod schema is converted to a MongoDB JSON Schema validator and attached to the collection. New collections get it via createCollection; existing ones are updated with collMod. Documents are then validated at two layers:
- Application (Zod) - runs inside Model methods, applies transforms and defaults, and produces detailed error messages.
- Database (MongoDB JSON Schema) - enforced by the MongoDB server, so it also catches writes from
rawCollection(), other services, and admin tools.
bypassSchema bypasses both layers, passing bypassDocumentValidation: true to MongoDB.
A few things worth knowing:
- The option is server-only; it is ignored on the client.
- Attachment is asynchronous. All write methods await it internally, so there is no race, but a failure (an unconvertible schema, or existing documents that violate it) surfaces as a rejection from the first CRUD operation, not from the constructor — a constructor cannot await.
- Validation uses
validationLevel: 'strict'andvalidationAction: 'error'.
See API Reference for the full option list.
Insecure Mode
When the insecure package is active (default for new Meteor projects), the package automatically adds permissive allow rules to prevent accidentally locking down your collection during development:
# Remove insecure mode in production meteor remove insecure
Once you remove the insecure package, you must define explicit allow rules for client-side operations to work.
Common Patterns
Role-Based Access Control:
1import { Roles } from 'meteor/alanning:roles'; 2 3PostModel.allow({ 4 insert: (userId) => userId !== null, 5 update: (userId, doc) => { 6 // Allow admins or the author 7 return Roles.userIsInRole(userId, 'admin') || doc.authorId === userId; 8 }, 9 remove: (userId) => Roles.userIsInRole(userId, 'admin'), 10});
Field-Level Restrictions:
1PostModel.deny({ 2 update: (userId, doc, fieldNames) => { 3 // Only admins can change published status 4 if (fieldNames.includes('published')) { 5 return !Roles.userIsInRole(userId, 'admin'); 6 } 7 return false; 8 }, 9});
Fetch Specific Fields for Rules:
1PostModel.allow({ 2 update: (userId, doc) => doc.authorId === userId, 3 fetch: ['authorId'], // Only fetch authorId from database for performance 4});
Migration from collection2
If you're migrating from collection2, the security model is very similar:
collection2:
1Posts.attachSchema(PostSchema);
typed:model:
1const PostModel = new Model({ name: 'posts', schema: PostSchema }); 2// Validation deny rules are automatically applied!
Both packages use the same underlying allow/deny mechanism, so your existing allow/deny rules should work with minimal changes. The main difference is that validation happens automatically without calling attachSchema().
See the Migration Guide for detailed migration instructions.
Running Tests
The package includes comprehensive test coverage for all core functionality. To run the tests:
Prerequisites
- Meteor 3.0.1 or later
- Node.js and npm
Install Dependencies
meteor npm install
Run Tests
meteor npm test
Or directly with Meteor:
TEST_BROWSER_DRIVER=playwright meteor test-packages ./ --once --driver-package meteortesting:mocha
First time setup: Install Playwright browsers:
npm run test:install-browsers
Or manually:
npx playwright install
Note: You may also need to install system dependencies for Playwright:
sudo npx playwright install-deps
Test Coverage
The test suite includes 143 comprehensive tests (111 server-side + 32 client-side):
Server-Side Tests (111 tests):
- Model CRUD Operations: Insert, update, upsert, and find operations with schema validation
- Custom Types: Auto-populated fields like
stringId,createdTimestamp,updatedTimestamp,createdUser, andupdatedUser - Schema Validation: Runtime validation with Zod and compile-time type safety
- MongoDB Operators: Support for
$set,$push,$addToSet,$inc,$unset, and other MongoDB update operators - Schema Relaxation: Conversion of strict schemas for flexible update operations
- JSON Schema Generation: MongoDB JSON Schema generation from Zod schemas
- Type Inference: Compile-time tests ensuring correct TypeScript type inference
- Allow/Deny Security: Auto-applied validation rules, custom allow/deny rules, rule evaluation order, server-only bypassSchema enforcement, error formatting, and integration with Model methods
- Protected Fields:
denyUntrusted()marker detection, field extraction, schema helper auto-protection, and deny rule registration
Client-Side Tests (32 tests):
- Package Loading: Verification that the package loads correctly on the client
- API Availability: Ensures Model, CustomTypes, and SchemaHelpers are accessible
- Model Instantiation: Confirms Model instances can be created on the client
- Protected Field Enforcement: Comprehensive testing of
denyUntrusted()protection:- Prevents client from setting protected fields on insert (via direct collection access)
- Prevents client from updating protected fields (via direct collection access)
- Prevents client from setting protected fields via
Model.insertAsync() - Prevents client from updating protected fields via
Model.updateAsync() - Works with all MongoDB operators ($set, $push, $unset, $inc, etc.)
- Auto-protects timestamp and user tracking fields from schema helpers
- Allows operations when protected fields are omitted
- Proper error messages with field names
Contributing
Contributions are welcome! Please:
- Check existing issues or create a new one to discuss your idea
- Fork the repository and create a feature branch
- Write tests for your changes
- Ensure all tests pass with
meteor npm test - Submit a pull request
Attribution
This package is composed mostly of code extracted from the JollyRoger project created by Evan Broder.
License
MIT