typed:model

v1.1.0Published yesterday

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 attachValidator generates MongoDB JSON Schema validators from your Zod schema
  • Custom Types: Pre-built Zod types for common patterns
  • Schema Helpers: Easy composition with withTimestamps, withUsers, and withCommon
  • 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, use typed: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

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, and withUsers automatically protect their fields

See Custom Types - denyUntrusted for detailed documentation and examples.

Rule Evaluation Order

Meteor evaluates security rules in a specific order:

  1. Deny rules are checked first - if any return true, the operation is rejected
  2. Allow rules are checked next - if any return true, the operation is permitted
  3. 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:

  1. Application (Zod) - runs inside Model methods, applies transforms and defaults, and produces detailed error messages.
  2. 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' and validationAction: '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, and updatedUser
  • 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:

  1. Check existing issues or create a new one to discuss your idea
  2. Fork the repository and create a feature branch
  3. Write tests for your changes
  4. Ensure all tests pass with meteor npm test
  5. Submit a pull request

Attribution

This package is composed mostly of code extracted from the JollyRoger project created by Evan Broder.

License

MIT