| name | linting-standards |
| description | Type safety standards and linting philosophy for TypeScript/Wasp projects. 2-tier approach (production strict, tests pragmatic). Helper patterns, mock patterns, when 'any' is acceptable vs cheating. |
| triggers | ["type safety","any type","no-explicit-any","helper pattern","mock cast","delegate pattern","context any","vi.mocked","type error","typescript strict","linting standard","prisma delegate","wasp context"] |
| version | 1 |
| last_updated | "2026-02-02T00:00:00.000Z" |
Linting Standards & Type Safety
Philosophy: Type safety in production code (0 tolerance), pragmatism in test infrastructure.
Reference: Full documentation at docs/LINTING-STANDARDS.md
Quick Reference
2-Tier Standard
| Context | Standard | any Usage | Why |
|---|
| Production code | Strict | 0 preferred | Type safety critical |
| Tests/mocks | Pragmatic | With inline comment | Practicality over purity |
Decision Trees
Helper Function Pattern:
- Needs 1-2 entities? → Delegate pattern (0
any)
- Needs 3+ entities? → Context any (with doc comment)
Test Mock Pattern:
- Try
vi.mocked() first → Modern approach (Vitest 3.x+)
- Type mismatch? →
as any fallback (with inline comment)
Why We Care About Types
The danger of any:
function processData(data: any) {
return data.items.map((item) => item.value.toFixed(2));
}
function processData(data: { items: Array<{ value: number }> }) {
return data.items.map((item) => item.value.toFixed(2));
}
Benefits:
- Catch bugs early (compile-time, not production)
- Self-documenting (types serve as inline docs)
- Refactoring confidence (TypeScript guides safe changes)
- Team consistency (shared understanding of data)
The 2-Tier Standard
Tier 1: Production Code (STRICT - 0 tolerance for any)
Applies to:
src/server/*/operations.ts - Backend operations
src/server/*/validators.ts - Input validation
src/server/permissions/*.ts - Authorization logic
- All React components
- All business logic
Rules:
- ❌ NEVER use
any type
- ❌ NEVER use
@ts-ignore or @ts-nocheck
- ❌ NEVER use broad eslint-disable comments
- ✅ USE proper types:
Prisma.JsonValue, unknown, specific types
- ✅ USE inline eslint-disable ONLY for unavoidable tool limitations (with comment)
Tier 2: Test Infrastructure (PRAGMATIC)
Applies to:
src/**/*.test.ts - Unit tests
src/**/*.test.tsx - Component tests
e2e-tests/**/*.spec.ts - E2E tests
Rules:
- ✅ Mock type casts MAY use
as any with inline comment
- ✅ Test helpers MAY use
unknown where appropriate
- ❌ Business logic in tests must still be properly typed
- ❌ Test data/assertions should use specific types where possible
Why different standards?
- Mocking - Test frameworks often have type mismatches
- Ergonomics - Tests should be easy to write/maintain
- Focus - Test quality matters more than mock infrastructure types
Helper Function Patterns
🥇 FIRST CHOICE: Delegate Pattern (0 any)
When: Helper needs 1-2 specific entities
export async function logTaskActivity(params: {
a3Id: string;
userId: string;
action: string;
a3ActivityDelegate: PrismaClient["a3Activity"]; // ✅ Specific delegate
}): Promise<TaskActivity> {
return taskRecordActivityDelegate.create({
data: { a3Id: params.a3Id, userId: params.userId, action: params.action },
});
}
export const createTask: CreateTask = async (args, context) => {
await logTaskActivity({
a3Id: a3.id,
userId: context.user.id,
action: "CREATED",
a3ActivityDelegate: context.entities.TaskActivity,
});
};
Why preferred:
- ✅ Zero
any types (100% type safety)
- ✅ Explicit dependencies (clear what entities helper needs)
- ✅ Easy to test (mock specific delegates)
- ✅ OpenSaaS recommended pattern
⚠️ FALLBACK: Context Any (When delegate becomes impractical)
When: Helper needs 3+ entities
export async function canAccessDepartment(
userId: string,
departmentId: string,
context: any,
): Promise<boolean> {
const membership = await context.entities.UserDepartment.findUnique({
where: { userId_departmentId: { userId, departmentId } },
});
return membership !== null;
}
When acceptable:
- Helper needs 3+ entities (delegate pattern too verbose)
- Complex cross-entity queries
- File header documents justification
- Inline eslint-disable with comment explaining reason
- Operations calling helpers ARE properly typed (safety at operation boundary)
Wasp 0.20+ Type Inference:
- Operations with type annotations (
GetTask<Args, Return>) get automatic context typing
- Helper functions outside operations lose this inference (framework limitation)
- Reevaluate helper patterns when Wasp adds official typing for helpers
Test Mock Patterns
✅ PREFERRED: vi.mocked() (Vitest 3.x+)
import { vi } from "vitest";
const mockLogActivity = vi.mocked(activityLog.logTaskActivity);
it("should log activity after creation", async () => {
mockLogActivity.mockResolvedValue({ id: "activity-1" });
});
✅ FALLBACK: Mock Type Cast (Complex cases)
const mockLogActivity = activityLog.logTaskActivity as any;
it("should log activity after creation", async () => {
mockLogActivity.mockResolvedValue({ id: "activity-1" });
});
Why this approach:
vi.mocked() provides proper typing in most cases (Vitest 3.x+)
as any fallback for edge cases (Prisma delegate types, complex generics)
- Isolated to test infrastructure
- Documented reason when using fallback
- Doesn't compromise test quality
When any is Acceptable
✅ ACCEPTABLE: CLI Scripts & Seed Data
export async function seedMultiTenant() {
console.log("Seeding multi-tenant data...");
}
Why acceptable:
- Script runs once during development
- Console output is the intended behavior
- Not part of production runtime
✅ ACCEPTABLE: Type Guards with Unknown
function validateData(data: unknown): void {
if (!data || typeof data !== "object") {
throw new Error("Invalid data");
}
}
When any is Cheating
❌ CHEATING: Hiding Production Code Issues
export const updateTask = async (args: { data: any }, context: any) => {
return context.entities.TaskDocument.update({
where: { id: args.id },
data: args.data,
});
};
import { Prisma } from "@prisma/client";
export const updateTask = async (
args: { data: Prisma.TaskDocumentUpdateInput },
context: Context,
) => {
return context.entities.TaskDocument.update({
where: { id: args.id },
data: args.data,
});
};
❌ CHEATING: Broad eslint-disable
function validateData(data: any) {
}
function processData(input: any) {
}
function storeData(record: any) {
}
function validateData(data: unknown) {
if (typeof data !== "object" || !data) {
throw new Error("Invalid data");
}
}
Inline eslint-disable Best Practices
ALWAYS include a comment explaining why:
const mockFn = module.func as any;
const mockFn = module.func as any;
Comment format:
// eslint-disable-next-line <rule> -- <reason why disable is needed>
Good reasons:
- "Mock cast: Framework types don't match"
- "CLI script: console output is intentional"
- "Type guard: narrowing from unknown"
- "Wasp context varies by operation, needs 3+ entities"
Bad reasons:
- "Too hard to type"
- "Faster this way"
- "Will fix later"
Decision Trees
Helper Function Decision
Does helper need database access?
├─ NO → Use proper TypeScript types (no context)
└─ YES → How many entities?
├─ 1-2 entities → Delegate pattern (PREFERRED)
│ Example: logTaskActivity(params, a3ActivityDelegate)
└─ 3+ entities → Context any with doc comment
Example: complexHelper(data, context: any)
⚠️ Requires: File header + inline comment
Test Mock Decision
Need to mock a function?
├─ Try: vi.mocked(module.function)
│ ├─ Works? → ✅ Use it
│ └─ Type mismatch?
│ └─ Use: module.function as any
│ ⚠️ Requires: inline eslint-disable with comment
Type Safety Decision
Got 'any' type?
├─ Production code?
│ ├─ YES → ❌ MUST FIX (0 tolerance)
│ │ Options:
│ │ ├─ Use Prisma.* types
│ │ ├─ Use unknown with type guards
│ │ └─ Use proper TypeScript types
│ └─ NO → Test infrastructure?
│ ├─ Mock cast? → ✅ OK with inline comment
│ └─ Business logic? → ❌ MUST FIX
Common Type Errors & Fixes
"Property X does not exist on type Y"
After schema.prisma or main.wasp changes:
./scripts/safe-start.sh
wasp clean
./scripts/safe-start.sh
Missing type annotation:
export const getTask = async (args, context) => {
};
export const getTask: GetTask<{ id: string }, TaskDocument> = async (
args,
context,
) => {
};
"Cannot find module 'wasp/...'"
./scripts/safe-start.sh
"Unexpected any"
Option 1: Fix with proper types
import { Prisma } from "@prisma/client";
const data: Prisma.TaskDocumentCreateInput = {
};
Option 2: Inline eslint-disable (tests only)
const mockFn = module.func as any;
ESLint Configuration
Why no-undef is Disabled for TypeScript
Problem: ESLint's no-undef doesn't understand TypeScript types (flags HTMLSelectElement, etc.)
Official TypeScript ESLint Recommendation:
"We strongly recommend you do NOT use the no-undef rule on TypeScript projects. TypeScript provides better checking."
Configuration:
{
files: ["**/*.ts", "**/*.tsx"],
rules: {
"no-undef": "off"
}
}
Why safe:
- TypeScript compiler already checks undefined variables
no-undef was designed for JavaScript, not TypeScript
- Modern TS projects (React, Vue, Angular) all disable it
Real-World Examples
Example 1: Production Operation (STRICT)
import type { UpdateTask } from "wasp/server/operations";
import { Prisma } from "@prisma/client";
export const updateTask: UpdateTask = async (args, context) => {
if (!context.user) throw new HttpError(401);
const updateData: Prisma.TaskDocumentUpdateInput = {};
if (args.data.title) updateData.title = args.data.title;
return context.entities.TaskDocument.update({
where: { id: args.id },
data: updateData,
});
};
Example 2: Helper with Delegate Pattern
export async function auditTaskChange(params: {
a3Id: string;
userId: string;
changes: Record<string, unknown>;
auditLogDelegate: PrismaClient["auditLog"];
}): Promise<AuditLog> {
return auditLogDelegate.create({
data: {
entityType: "TaskDocument",
entityId: params.a3Id,
userId: params.userId,
changes: params.changes,
},
});
}
Example 3: Test Mock (PRAGMATIC)
import { vi } from "vitest";
const mockAudit = vi.mocked(auditLog.auditTaskChange);
const mockAudit = auditLog.auditTaskChange as any;
it("audits Task changes", async () => {
mockAudit.mockResolvedValue({ id: "audit-1" });
});
FAQ
Q: Should I use vi.mocked() or as any for test mocks?
A: Try vi.mocked() first (Vitest 3.x+), fall back to as any if needed.
const mockFn = vi.mocked(module.function);
const mockFn = module.function as any;
Q: When should I use unknown vs any?
Production code:
- ✅
unknown - Forces type checking before use
- ❌
any - Disables all type checking
Test mocks:
- ✅
vi.mocked() - Try first (Vitest 3.x+)
- ✅
as any - Fallback for complex type mismatches (with inline comment)
- ❌
as unknown - Doesn't solve mock type issues
Q: Can I use @ts-ignore?
A: Almost never. Use:
- Proper types first
- Inline
eslint-disable with comment if unavoidable
@ts-expect-error if testing error handling
@ts-ignore silences errors without explanation.
Q: Helper needs 2 entities - delegate or context any?
A: Delegate pattern (0 any). Only use context any for 3+ entities.
Summary
Production Operations: Properly typed with Wasp's generated types
Helper Functions:
- 🥇 Delegate pattern (0 any) - Pass specific delegates (1-2 entities)
- ⚠️ Context any with doc - Only for complex helpers needing 3+ entities
Test Mocking:
- ✅ Try
vi.mocked() first (Vitest 3.x+)
- ✅ Fallback to
as any for complex cases (with inline comment)
- Test business logic still properly typed
When in doubt:
- Helper needs 1-2 entities? → Delegate pattern
- Helper needs 3+ entities? → Context any with doc
- Test mock? → Try
vi.mocked() first
- Does this compromise operation type safety? If no, pragmatism wins.
Key Principle: Wasp 0.20 operations get automatic type inference. Helpers outside operations don't - use delegate pattern where possible, document when context any is needed.
References
- Full documentation: docs/LINTING-STANDARDS.md
- Code quality procedures:
code-quality skill
- Wasp operations:
wasp-operations skill
- Troubleshooting:
troubleshooting-guide skill
- ESLint config:
app/eslint.config.js