Standards extracted from the Schmock codebase. All contributors and AI assistants must follow these conventions.
TypeScript strict mode is enabled project-wide. Never use // @ts-ignore or // @ts-expect-error to suppress errors — fix the root cause.
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"strict": true,
"moduleResolution": "bundler"
}
}Ambient types live in packages/core/schmock.d.ts and are the single source of truth. Packages re-export them via types.ts:
/// <reference path="../../core/schmock.d.ts" />
export type HttpMethod = Schmock.HttpMethod;
export type RouteKey = Schmock.RouteKey;Prefer type narrowing over casting. Use type guards to narrow types at runtime:
// Good — runtime validation narrows the type
export function isHttpMethod(method: string): method is HttpMethod {
return HTTP_METHODS.includes(method as HttpMethod);
}
export function toHttpMethod(method: string): HttpMethod {
const upper = method.toUpperCase();
if (!isHttpMethod(upper)) {
throw new Error(`Invalid HTTP method: "${method}"`);
}
return upper;
}
// Avoid — blind cast
const method = value as HttpMethod;Use as const for literal arrays and objects:
export const HTTP_METHODS: readonly HttpMethod[] = [
"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS",
] as const;Use template literal types for structured strings:
type RouteKey = `${HttpMethod} ${string}`;Use union types for flexible APIs:
type ResponseResult =
| ResponseBody
| [number, unknown] // [status, body]
| [number, unknown, Record<string, string>]; // [status, body, headers]Use parameter objects instead of long parameter lists:
// Good
interface SchemaGenerationContext {
schema: JSONSchema7;
count?: number;
overrides?: Record<string, any>;
params?: Record<string, string>;
}
function generateFromSchema(options: SchemaGenerationContext): any
// Avoid
function generateFromSchema(
schema: JSONSchema7, count?: number,
overrides?: Record<string, any>, params?: Record<string, string>
): anySeparate type imports from value imports. Group by origin:
// 1. Type-only imports (external)
import type { HttpEvent, HttpHandler, HttpInterceptor } from "@angular/common/http";
// 2. Value imports (external)
import { HTTP_INTERCEPTORS, HttpErrorResponse } from "@angular/common/http";
import { Injectable } from "@angular/core";
// 3. Internal imports
import type { CallableMockInstance } from "@schmock/core";
import { ROUTE_NOT_FOUND_CODE, SchmockError } from "@schmock/core";
// 4. Relative imports
import { parseRouteKey } from "./parser";
import { PluginError, RouteNotFoundError } from "./errors";Biome enforces import organization automatically via organizeImports: "on".
| Category | Convention | Examples |
|---|---|---|
| Types / Interfaces | PascalCase | PluginContext, ResponseResult |
| Type aliases | PascalCase | RouteKey, HttpMethod |
| Variables / Parameters | camelCase | globalConfig, matchedRoute |
| Constants | UPPER_SNAKE_CASE | ROUTE_NOT_FOUND_CODE, HTTP_METHODS |
| Functions | camelCase | parseRouteKey(), extractParams() |
| Error classes | PascalCase + Error |
RouteNotFoundError, PluginError |
| Files | kebab-case or dot-separated | builder.ts, parser.property.test.ts |
| Test files | *.test.ts |
index.test.ts, errors.test.ts |
| Step definitions | *.steps.ts |
fluent-api.steps.ts |
| BDD features | kebab-case .feature |
fluent-api.feature, error-handling.feature |
Prefer pure functions — no side effects, deterministic output. Thread state explicitly:
// Good — pure, tracks path via parameter
function validateSchema(schema: JSONSchema7, path = "$"): void {
if (schema.type === "object" && schema.properties) {
for (const [name, prop] of Object.entries(schema.properties)) {
validateSchema(prop as JSONSchema7, `${path}.properties.${name}`);
}
}
}
// Good — graph traversal with explicit state
function hasCircularReference(schema: JSONSchema7, visited = new Set()): boolean {
if (visited.has(schema)) return true;
visited.add(schema);
// ... traverse children
visited.delete(schema); // backtrack
return false;
}Exit early for guard conditions. Avoid deep nesting:
// Good
private async applyDelay(): Promise<void> {
if (!this.globalConfig.delay) return;
const delay = Array.isArray(this.globalConfig.delay)
? Math.random() * (this.globalConfig.delay[1] - this.globalConfig.delay[0]) + this.globalConfig.delay[0]
: this.globalConfig.delay;
await new Promise((resolve) => setTimeout(resolve, delay));
}Create copies instead of mutating inputs:
// Good — shallow copy, then modify
function enhanceFieldSchema(fieldName: string, fieldSchema: JSONSchema7): JSONSchema7 {
const enhanced = { ...fieldSchema };
// modify enhanced, not fieldSchema
return enhanced;
}
// Good — deep clone before mutation
function applyOverrides(data: any, overrides?: Record<string, any>): any {
if (!overrides) return data;
const result = JSON.parse(JSON.stringify(data));
// modify result
return result;
}Use a clear priority chain — explicit value > constraints > default constant:
function determineArrayCount(schema: JSONSchema7, explicitCount?: number): number {
if (explicitCount !== undefined) return Math.max(0, explicitCount);
if (schema.minItems !== undefined && schema.maxItems !== undefined) {
return Math.floor(Math.random() * (schema.maxItems - schema.minItems + 1)) + schema.minItems;
}
return DEFAULT_ARRAY_COUNT;
}const {
errorFormatter,
passErrorsToNext = true,
transformHeaders = defaultTransformHeaders,
beforeRequest,
} = options;All errors extend SchmockError with a machine-readable code and structured context:
export class SchmockError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly context?: unknown,
) {
super(message);
this.name = "SchmockError";
if (typeof Error.captureStackTrace === "function") {
Error.captureStackTrace(this, this.constructor);
}
}
}Domain-specific errors carry relevant context:
export class SchemaValidationError extends SchmockError {
constructor(schemaPath: string, issue: string, suggestion?: string) {
super(
`Schema validation failed at ${schemaPath}: ${issue}${suggestion ? `. ${suggestion}` : ""}`,
"SCHEMA_VALIDATION_ERROR",
{ schemaPath, issue, suggestion },
);
this.name = "SchemaValidationError";
}
}Public-facing methods like handle() catch errors and return a response:
async handle(method, path, options?): Promise<Response> {
try {
// ... process request
} catch (error) {
return {
status: 500,
body: {
error: (error as Error).message,
code: error instanceof SchmockError ? error.code : "INTERNAL_ERROR",
},
headers: {},
};
}
}try {
return generateFromSchema({ schema });
} catch (error) {
if (error instanceof SchemaValidationError || error instanceof ResourceLimitError) {
throw error; // re-throw domain errors as-is
}
throw new SchemaGenerationError(
context.path,
error instanceof Error ? error : new Error(String(error)),
schema,
);
}Validate at configuration time, not at request time:
export function fakerPlugin(options: FakerPluginOptions): Plugin {
validateSchema(options.schema); // throws immediately if invalid
return { /* plugin */ };
}Name all magic numbers and strings. Centralize limits:
const MAX_ARRAY_SIZE = 10000;
const MAX_NESTING_DEPTH = 10;
const DEFAULT_ARRAY_COUNT = 3;
export const ROUTE_NOT_FOUND_CODE = "ROUTE_NOT_FOUND" as const;Each package follows:
packages/<name>/
├── src/
│ ├── index.ts # Public API, exports
│ ├── types.ts # Type re-exports (if needed)
│ ├── <module>.ts # Implementation modules
│ ├── errors.ts # Custom errors (if needed)
│ ├── constants.ts # Constants (if needed)
│ ├── *.test.ts # Unit tests
│ └── steps/
│ └── *.steps.ts # BDD step definitions
├── tsconfig.json
└── package.json
index.tsis the public API — re-exports only what consumers need- Group exports by category: constants, errors, types
- Internal helpers stay in their module, never exported from
index.ts
// index.ts — organized re-exports
export { HTTP_METHODS, isHttpMethod, ROUTE_NOT_FOUND_CODE } from "./constants";
export { PluginError, RouteNotFoundError, SchemaValidationError } from "./errors";
export type { CallableMockInstance, Generator, HttpMethod } from "./types";One concern per file:
builder.ts— route management and request handlingparser.ts— route key parsing onlyerrors.ts— error definitions onlyconstants.ts— constants and type guards
Prefer factory functions over classes for public APIs:
// Express — returns middleware
export function toExpress(mock: CallableMockInstance, options = {}): RequestHandler {
return async (req, res, next) => { /* ... */ };
}
// Angular — returns class constructor for DI
export function createSchmockInterceptor(mock, options = {}): new () => HttpInterceptor {
@Injectable()
class SchmockInterceptor implements HttpInterceptor { /* ... */ }
return SchmockInterceptor;
}Plugins implement process() and optionally onError():
interface Plugin {
name: string;
version?: string;
process(context: PluginContext, response?: any): PluginResult | Promise<PluginResult>;
onError?(error: Error, context: PluginContext): Error | ResponseResult | void;
}Design plugins to be pipeline-aware — pass through response if none exists, transform if one does:
process(context, response) {
if (response) {
return { context, response: transform(response) };
}
return { context, response };
}Defer expensive setup until first use:
let jsfConfigured = false;
function getJsf() {
if (!jsfConfigured) {
jsf.extend("faker", () => createFakerInstance());
jsf.option({ requiredOnly: false, alwaysFakeOptionals: true });
jsfConfigured = true;
}
return jsf;
}Comment the why, never the what. The code should be self-explanatory:
// Good — explains a design decision
// Two-pass matching: static routes first for performance, then parameterized
private findRoute(method: HttpMethod, path: string): CompiledCallableRoute | undefined {
// Good — explains non-obvious behavior
// Even error responses get delayed to simulate realistic latency
await this.applyDelay();Use JSDoc on public APIs with @example blocks:
/**
* Create a new Schmock mock instance with callable API.
*
* @example
* ```typescript
* const mock = schmock({ debug: true })
* mock('GET /users', () => [{ id: 1, name: 'John' }])
* const response = await mock.handle('GET', '/users')
* ```
*
* @param config Optional global configuration
* @returns A callable mock instance
*/
export function schmock(config?: GlobalConfig): CallableMockInstanceNo JSDoc on internal/private methods unless the logic is non-obvious.
| Layer | Tool | Location | Purpose |
|---|---|---|---|
| BDD | vitest-cucumber | features/*.feature + packages/*/src/steps/*.steps.ts |
Behavior specification |
| Unit | Vitest | packages/*/src/*.test.ts |
Implementation correctness |
| Property | fast-check | packages/*/src/*.property.test.ts |
Edge cases, fuzzing |
Follow the Gherkin pattern — user story + design notes + scenarios:
Feature: Callable API
As a developer
I want to define mocks using a direct callable API
So that I can create readable and maintainable mocks
# Design Decision: callable factory function pattern
# - Direct and minimal boilerplate
# - No need for build() calls
Scenario: Simple route with generator function
Given I create a mock with:
"""
const mock = schmock({})
mock('GET /users', () => [{ id: 1, name: 'John' }], { contentType: 'application/json' })
"""
When I request "GET /users"
Then I should receive:
"""
[{ "id": 1, "name": "John" }]
"""Three-level describe hierarchy — feature, category, specific test:
describe("Schema Generator", () => {
describe("Core Functionality", () => {
it("generates data from simple schemas", () => { /* ... */ });
it("generates arrays with specified count", () => { /* ... */ });
});
describe("Schema Validation", () => {
describe("invalid schemas", () => {
it("rejects empty schema objects", () => { /* ... */ });
});
});
});Test names read as specifications — describe what the code does, not how:
// Good
it("rejects empty schema objects", () => { /* ... */ });
it("enforces array size limits", () => { /* ... */ });
// Avoid
it("should throw when schema is empty", () => { /* ... */ });
it("test array size", () => { /* ... */ });Use factory functions and semantic assertion helpers:
// Factories for test data
export const schemas = {
simple: {
object: (properties = {}): JSONSchema7 => ({ type: "object", properties }),
array: (items: JSONSchema7): JSONSchema7 => ({ type: "array", items }),
},
};
// Semantic assertions
export const schemaTests = {
expectValid: (schema: JSONSchema7): void => {
expect(() => generateFromSchema({ schema })).not.toThrow();
},
expectSchemaError: (schema: any, path: string): void => {
// validates error type, path, and message
},
};Use vi.fn() with partial interfaces and as unknown as Type:
function createMock(handleFn: (...args: any[]) => any): CallableMockInstance {
return { handle: vi.fn(handleFn), pipe: vi.fn() } as any;
}
function createRes() {
return {
status: vi.fn().mockReturnThis(),
json: vi.fn(),
send: vi.fn(),
end: vi.fn(),
} as unknown as Response;
}Use fast-check for fuzz testing parsers and validators:
it("never throws an unhandled error on arbitrary input", () => {
fc.assert(
fc.property(arbitraryString, (input) => {
try {
parseRouteKey(input);
} catch (e) {
expect(e).toBeInstanceOf(RouteParseError);
}
}),
{ numRuns: 1000 },
);
});Biome enforces these rules (see biome.json):
| Rule | Level | Rationale |
|---|---|---|
noParameterAssign |
error | Enforce immutability of function parameters |
useAsConstAssertion |
error | Prefer as const for literals |
useDefaultParameterLast |
error | Default params at end of signature |
noUnusedTemplateLiteral |
error | Use plain strings when no interpolation |
useNumberNamespace |
error | Use Number.parseInt over parseInt |
noInferrableTypes |
warn | Don't annotate types TS can infer |
noUselessElse |
warn | Use early returns over else blocks |
noUnusedFunctionParameters |
warn | Remove unused parameters |
noExplicitAny |
off | any is allowed where type safety isn't practical |
Formatting: spaces (not tabs), auto-format on save.
- Branch flow:
feature/*->develop->main - Commits: conventional commit format, no Claude signatures
- Pre-commit hooks: lint + full test suite (
bun run setupto configure) - Quiet commands:
bun lint:quiet,bun test:quiet,bun build:quiet