This document describes the coding conventions used in ContractCase that are not enforced by lint or covered by CodingStandards.md. Where CodingStandards.md tells you what the standards are, this document records the conventions that live between the lint rules — and, importantly, why each one exists, including its known costs. If a convention's trade-off starts to outweigh its motivation, it should be revisited rather than followed blindly.
Formatting is Prettier's job, and many style rules are enforced by
eslint-config-case-maintainer (no any, no loops, no console, no default
exports, no parameter mutation, and so on). Nothing in this document repeats
what a machine already checks. Everything here is convention: it only survives
through review and through this document.
- Functions, closures, and classes
- Dependency injection
- Exports
- Asynchronous code
- Error handling
- Control flow and data
- Types
- Naming
- Context threading and logging
- Plugin executor conventions
- Documentation and comment voice
- Tests
- Cross-language bindings
- Known deviations
There are no function declarations in the codebase. Every function, including
internal helpers, is written as:
export const errorHandler = (e: Error): never => {
...
};Why: uniformity — one function syntax means one thing to read; explicit return types on exports are lint-enforced, but we extend the habit to internal helpers so that a wrong implementation fails at the definition, not at some distant call site.
When a "service" needs configuration or private state, write a factory that takes the configuration and returns an object of closures:
export const makeBrokerApi = (context: DataContext): BrokerApi => ({
publishContract: (...) => ...,
downloadContract: (...) => ...,
});Examples: makeLogger, makeBrokerApi, makeResultFormatter,
makeFunctionRegistry, makeAssertionsOn.
Why: a closure's captured state cannot be reached or mutated from outside —
there is no this, no visibility modifier to forget, and no temptation to add
setters. Factories also make dependencies explicit: everything the service
uses arrives through the factory's parameters.
Cost: no instanceof, and each instance allocates its own function
objects. This is fine at ContractCase's scale (services are created a handful
of times per run, not per matcher).
- The user-facing DSL surface (
case-definition-dslmatchers, interactions, states) — classes give users a discoverable, documentable API and let JSii translate it to other languages. Inheritance is acceptable here (BasicAuthHeaderValue extends StringPrefix) because the hierarchy is the user-facing taxonomy. - Stateful lifecycle orchestrators —
WritingCaseContract,ContractVerifier,PluginLoader,BrokerService. These genuinely hold evolving state across a run. - Error types — see Error handling.
Anything that doesn't fit one of these roles should be a function or a make*
factory. If you're writing a class and it isn't one of these three things,
stop and reconsider.
Why: the split maps to audience. Classes appear where fluency and discoverability help a user; functions appear where composability helps a maintainer. Keeping the class population small also keeps the "where is the state?" question easy to answer.
Collaborators are injected as explicit dependency bundles, with production wiring supplied through a default argument:
constructor(
config: CaseConfig,
printer: ResultFormatter,
dependencies: WriterDependencies = writerDependencies(printer),
) { ... }The bundles themselves (WriterDependencies, ReaderDependencies) are plain
objects of factory functions, assembled in one place
(case-core/src/connectors/dependencies.ts).
Why: this is the reason the test suites contain zero
jest.mock / jest.fn / jest.spyOn calls. Tests construct the real object
and hand it real (or trivially faked) collaborators. Tests written this way
survive refactors that would shred a module-mocking suite, and they can never
pass against an interface that no longer exists.
Cost: the default argument hides the production wiring inside a signature, and the dependency bundles must be maintained by hand. Accept both; the no-mocking-library property is worth it. Do not introduce a mocking framework.
Named exports only — this is lint-enforced — but there are two sanctioned uses
of export default, and the exception carries meaning:
-
A plugin package default-exports its assembled plugin object (and only that):
// case-core-plugin-http/src/index.ts export default CoreHttpPlugin;
-
A module that is a single data blob may default-export it — the
case-definition-generator/src/entities/*.tsDSL declarations.
Why: when defaults are this rare, seeing one tells you what kind of module you're in: "this module is the thing", not "this module contains things".
Public package entry points separate type re-exports with export type { ... }
so that API Extractor and bundlers can see the type-only surface.
validate: (matcher, matchContext) =>
Promise.resolve()
.then(() => validateCodes(matcher, matchContext))
.then(() => descendAndValidate(...)),Why: async/await makes it easy to write long functions that nest
promises aggressively while looking flat and innocent. A .then() chain keeps
each step small and makes the shape of the pipeline — including where errors
can enter and where they're handled — explicit in the code's structure. If a
chain is getting awkward, that's usually a sign the steps want to be named
functions, not a sign to switch to await.
async/await is not banned; it appears where a genuinely sequential
narrative reads better (for example BrokerService.downloadContracts). But
the default is the chain, and "this would be easier with await" should
prompt "should this be several tighter functions?" first.
validate: (matcher, matchContext) =>
Promise.resolve().then(() => {
if (somethingWrong) {
throw new CaseConfigurationError(...);
}
return descendAndValidate(...);
}),Why: it funnels synchronous throws into the promise chain, so callers never
face the "did this throw synchronously or reject?" split. Every validate
implementation uses this opener even when its body is synchronous, so all
executors present a uniform async signature.
Every thrown error answers the question "whose fault is this?":
CaseConfigurationError— the user misconfigured something. Carries aConfigurationErrorCodeand remediation advice.CaseCoreError— this is a bug in ContractCase. Messages say so plainly ("This should not be possible. Please open an issue").
(Plus narrower siblings where the blame is more specific: CaseTriggerError
for a failing user trigger, ConnectorError for bugs in a wrapper library.)
Why: ContractCase is a testing tool; its single most important UX property is that a failing user always knows whether to fix their own configuration or file a bug. Encoding blame in the type means every throw site is forced to make that call, and every error-rendering path can act on it.
When throwing, choose deliberately, and if the choice is non-obvious, say why in a comment ("This is a CoreError because the ArgumentsMatcher is an internal matcher").
Every error class repeats the same construction pattern:
constructor(message: string, context: LogLevelContext | 'DONT_ADD_LOCATION', ...) {
super(...);
Object.setPrototypeOf(this, new.target.prototype); // TS extends-Error fix
this.name = 'CaseConfigurationError';
...
}The Object.setPrototypeOf line and the explicit this.name literal are both
load-bearing: the first fixes instanceof under TypeScript's Error
subclassing, and the second supports name-based dispatch (below).
switch (e.name) {
case 'CaseConfigurationError':
case 'ContractCaseConfigurationError':
...
}Why: errors cross package and process boundaries (core → connector →
foreign-language DSL), where instanceof breaks — duplicate package
instances, serialisation, and gRPC all destroy prototype identity. The name
string survives everything.
Cost: it's stringly typed; renaming an error class silently breaks
dispatch. This is why this.name is always a literal, set right next to the
class declaration, and why renames of error classes need a repo-wide search.
Functions whose job is to convert errors into wire-safe values
(jsErrorToFailure in TypeScript, safeExecute in Java) must not be able to
throw, and carry a comment saying so:
// NOTE: It must not be possible for this function to throw an errorWhy: a throw inside the error path escapes the stream/process boundary and surfaces as an unrelated crash far from the real failure. The convention is enforced only by comment and review — treat any modification to these functions as high-risk.
Matching failures are not exceptions. A check returns a MatchResult — an
array of CaseError where the empty array means success — built only through
the combinators makeResults, makeNoErrorResult, combineResults,
errorWhen, matchingError, and queried with hasErrors / hasNoErrors.
Why: a matcher tree should report every mismatch in a run, not just the first. Accumulating errors as values is the natural model for that; exceptions would abort at the first mismatch. The rule of thumb:
- The data didn't match → return error values.
- The matcher itself is malformed → throw
CaseConfigurationError. - Neither should be possible → throw
CaseCoreError.
Error messages are complete, conversational sentences that:
- state what's wrong first, then explain (per the Design Values);
- quote the offending value in single quotes:
`The string '${actual}' did not include the expected substring '${expected}'`; - attribute blame explicitly where it helps ("This is a configuration problem, and probably not a bug with ContractCase");
- offer the next step ("Please confirm that you are calling the mock server, and not your real server").
Log via context.logger.error(...) immediately before throwing when there is
internal state a maintainer would want; the log can carry the deep dump, the
error message stays user-sized.
Where a parameter needs an "opt out" mode, use a self-describing string literal union member rather than a boolean or a nullable:
context: LogLevelContext | 'DONT_ADD_LOCATION';Why: at the call site, 'DONT_ADD_LOCATION' reads as documentation;
false or undefined reads as nothing.
Every switch over a discriminated union ends with:
default:
throw new UnreachableError(type);where UnreachableError's constructor takes value: never, so adding a union
member without handling it is a compile error, not a runtime surprise.
The Java equivalent is an exhaustive switch expression whose
default/UNRECOGNIZED arms throw ContractCaseCoreError.
Some older sites instead pair a default: branch with an all-caps warning
comment ("ALL error types must be checked in this function"). These
predate the UnreachableError pattern and should be migrated to it as they
are touched — see Known deviations.
Prefer returning an expression to mutating your way to a result: function bodies are frequently a single returned ternary or combinator call, objects are built with spread, and optional members use the conditional-spread idiom:
...(matcher.body ? { body: strip(matcher.body, context) } : {}),Why: expression style plus immutability means a reader can trace where any value came from without simulating mutations. The lint config does much of the pushing here (no loops, no parameter mutation, reduce-with-initial-value); the conventions above are how the code looks when you comply gracefully.
Cost: a returned multi-branch ternary is hard to breakpoint. If you can't debug it, name the branches as small functions rather than converting the body to imperative statements.
Use loose == null / != null to test for "no value" — it covers both null
and undefined, and eqeqeq is configured to allow exactly this case.
Use === undefined only when the distinction between null and undefined
genuinely matters (for example, protobuf unboxing where undefined means
"field absent"), and add a comment saying the distinction is intentional.
An uncommented === undefined should be treated as a probable mistake — see
Known deviations.
Beyond what CodingStandards.md covers:
export const STRING_CONTAINS_TYPE = '_case:StringContains' as const;
export type AnyStringMatcherType =
| typeof STRING_CONTAINS_TYPE
| typeof STRING_PREFIX_TYPE;The constant is then used everywhere the tag appears: in the matcher interface
('_case:matcher:type': typeof STRING_CONTAINS_TYPE), in registry keys
([STRING_CONTAINS_TYPE]: StringContainsMatcherExecutor), and in the
executor's generic parameter.
Why: the value and its type have a single source, so they can never drift, and registries keyed by computed properties get exhaustiveness checking from the mapped registry type for free.
To pick one member out of a big union by its discriminant, define a tiny
marker interface and an Extract alias, rather than repeating conditional
types at use sites:
interface IsCaseNodeForType<T extends AnyCaseNodeType> {
'_case:matcher:type': T;
}
export type CaseNodeFor<T extends AnyCaseNodeType> = Extract<
AnyCaseMatcher,
IsCaseNodeForType<T>
>;Prefer giving each union member its own '_case:matcher:type' (or kind)
value over sharing one tag and narrowing with 'someField' in matcher.
Presence-based narrowing forced the cast-then-check guard style in the
function plugin and is a known soundness hole; new unions should not repeat
it.
Members of exported interfaces and type aliases should be readonly, and
array-typed members should be ReadonlyArray<T>:
export interface DescribeSegment {
readonly kind: 'message';
readonly segments: ReadonlyArray<DescribeSegment>;
}Why: the Design Values commit us to immutable matchers and context;
readonly makes the compiler enforce what is otherwise only convention.
Older matcher descriptor interfaces predate this rule and lack readonly —
add it as those files are touched (see Known deviations).
The rare deliberate mutation (for example a module-scope counter) is fine, but
should be isolated, small, and obvious — let exampleId = 0 next to the one
function that increments it.
A plain types.ts holds a module's type declarations. When one types.ts
would grow too large, split it into several <topic>.types.ts files in the
same directory (definitions.types.ts, constants.types.ts,
utility.types.ts, ...), usually re-exported together.
Why: the alternative — a subdirectory created just to hold types — adds a
navigation level for no benefit. The suffix keeps the split flat while still
signalling "this file is declaration surface". A .types.ts file may carry
small runtime companions that belong with the declarations (tag constants,
type guards); the suffix means "primarily types", not "no runtime".
Values of unknown provenance — matcher input, caught errors, parsed JSON —
enter typed code as unknown (never any; lint enforces this) and are
narrowed with is* type-guard functions. Caught errors are read as
(e as Error).message when the catch block cannot do better.
Additions to the CodingStandards.md naming table. These prefixes and suffixes encode a type or function's role, which matters more as the package count grows:
| Pattern | Meaning | Examples |
|---|---|---|
Any* |
The top of a union — "any member of" | AnyCaseMatcher, AnyMockDescriptor |
All* |
An aggregate of every member (usually a map or union of unions) | AllHttpMatcherTypes, AllSetup |
Core* |
Internal wire-format matcher shape (as opposed to the user-facing DSL class) | CoreStringContainsMatcher |
*Descriptor |
Serialisable description of a mock/interaction | MockFunctionDescriptor |
*Fn / *Fns |
Function type alias / bundle of function types | StripMatcherFn, TraversalFns |
*Executor |
The runtime object implementing a matcher or mock (see below) | StringContainsMatcherExecutor |
Wire* |
Import alias for a protobuf/generated wire type | DefinitionRequest as WireDefinitionRequest |
make* |
Factory returning an object of closures or a constructed value | makeLogger, makeResults |
is* / has* |
Boolean predicate, usually a type guard (x is T) |
isCaseNode, hasExample |
must* |
Resolver that returns the value or throws (assert-or-throw) | mustResolveToString |
*Internal |
Private worker function behind a public wrapper | assertPresentInternal |
Two specific rulings:
- Executor objects take the
Executorsuffix. The DSL class isStringContains, the wire shape isCoreStringContainsMatcher, the runtime object isStringContainsMatcherExecutor. (The HTTP plugin's executors currently omit the suffix; see Known deviations.) Wirealiasing is mandatory in the connector. Generated protobuf types never appear under their bare names next to domain types; the alias makes "which side of the boundary am I on?" visible in every expression.
Nearly every function takes an immutable context / matchContext as its
final parameter. The context carries the logger, resolved config, lookup
functions, and — critically — the current location trail. Descending into
a child matcher goes through addLocation:
context.descendAndValidate(
matcher['_case:matcher:contains'],
addLocation(':stringContains', matchContext),
);Why: the location trail is the backbone of ContractCase's error UX — it is what lets an error say where in a deeply nested matcher tree the mismatch happened. Threading the context explicitly (rather than using module state) keeps every function pure and testable.
Costs and rules of thumb:
- The parameter is viral. Accept the plumbing; do not "save" a signature by reaching for module state.
- Forgetting
addLocationdoesn't fail any test — it silently degrades error messages. When reviewing a matcher, check every descent adds a location. - Context is never mutated; transforms are
{ ...context, ...overrides }(with derived function members recomputed).
All logging goes through context.logger (never console; lint-enforced).
The five levels split by audience, not just severity:
error,warn,debug— for users.maintainerDebug,deepMaintainerDebug— for ContractCase maintainers; used to dump internal state, especially just before a throw.
Log generously. The core narrates its own execution ("This interaction passed all assertions"), and the maintainer levels exist precisely so verbosity never has to be traded against user-facing noise.
Every matcher executor is an object literal with exactly four members, typed
as MatcherExecutor<typeof TAG, CoreShape>:
export const StringContainsMatcherExecutor: MatcherExecutor<
typeof STRING_CONTAINS_TYPE,
CoreStringContainsMatcher
> = {
describe, // human-readable description, via the describe combinators
check, // returns MatchResult (errors as values)
strip, // returns the raw example data, or throws StripUnsupportedError
validate, // Promise; throws CaseConfigurationError on bad definitions
};Conventions around the shape:
- Define the four members as named
consts in the file and assemble them at the bottom, rather than writing large inline methods — the assembled object then reads as a table of contents. - The plugin object registers executors in a map keyed by the tag constants
with computed properties:
[STRING_CONTAINS_TYPE]: StringContainsMatcherExecutor. - One executor per file, file named after the export.
Why: the uniform shape is what makes thirty matcher files instantly
legible once you have read one, and the tag-keyed registry map gets its
exhaustiveness checked by the mapped AllExecutors type.
Each plugin's user-facing DSL is declared as a plain typed data structure
(PluginDslDeclaration) whose entries carry their documentation as strings.
Code generation turns this into the TypeScript and Java DSLs.
Why: one source of truth means the language bindings cannot drift from each other in behaviour or documentation. The cost is that the declaration duplicates information that also exists at the type level; keep the two in sync when adding matchers (this is the price of cross-language generation).
Everything exported carries a TSDoc release tag (@public or @internal) —
API Extractor consumes these, and tsdoc/syntax lint keeps them valid.
Beyond the tag conventions in CodingStandards.md:
- Public config interfaces document every field, with
@defaultValue. - Class-level doc comments are duplicated onto the constructor in
case-definition-dsl, so documentation renders in both places the doc tooling looks. Yes, it's duplication; keep the two in sync (the@privateRemarksnotes in those files say to update them by search-and-replace, and mean it). - The
override toJSON(): unknown { return super.toJSON(); }boilerplate on DSL matchers exists purely to satisfy JSii/docgen. Copy it exactly; never hand-edit one instance. @deprecatedalways carries a migration note. Deprecated API is documented out, not deleted.
Internal mappers and helpers have near-zero JSDoc — the executor shapes and naming conventions are expected to carry the what. Comments that do exist explain constraints and reasoning, and the house voice is candid:
TODO:comments are left in production code as honest markers of known debt, with enough context to act on later ("Theacceptsconcept is unused, we should remove it").- Hacks are confessed where they live ("Hack since this object isn't constructed anyway"; "it's a bit of a land mine"), including open questions addressed to future maintainers.
Why: a reader who inherits the author's doubts debugs faster than one who inherits confident-looking code. Do not launder a hack into silence — either fix it or label it.
Additions to the testing patterns in CodingStandards.md:
Tests instantiate the real unit with its real dependency bundle, substituting only the edges:
const contract = new WritingCaseContract(
CONTRACT,
{ ...writerDependencies(defaultPrinter) },
...
);Fakes are hand-rolled objects of plain functions
(printMatchError: () => Promise.resolve()), not jest.fn(). See
Dependency injection for the motivation. A
jest.clearAllMocks() in this codebase is a sign something was copy-pasted.
Build test data with small functions that parameterise the one field under test:
const consumerContract = (consumerName: string): ContractData => ({ ... });Shared assertion helpers are make* factories too, and may generate whole
describe/it blocks (makeExpectErrorContaining(contract)); the necessary
lint relaxations (jest/no-export, jest/expect-expect with
expectErrorContaining registered) are applied file-locally or in the shared
config.
Why: factories make each test state its own delta from a valid baseline; test-generating helpers keep matcher coverage matrices exhaustive without copy-paste. Cost: generated tests make "which line failed?" one step less direct — keep the generating helpers small and close to their use.
Large context fixtures (EMPTY_CONTEXT-style blocks) should be shared, not
copy-pasted between spec files — the current duplication across the plugin
spec files is a known smell, not a pattern to follow.
foo.spec.ts— colocated unit/integration test.index.<feature>.spec.ts— feature-scoped suites at a package root (index.http.client.define.spec.ts).*.spec.verify.ts— verification-side counterpart of a definition suite.*.spec-norun.ts— executable documentation examples, compiled but not run in the normal test target.*.case.spec.ts— suites that are themselves ContractCase contract tests.
DSL packages include "it compiles" tests that assign a constructed DSL object to its expected core type and assert non-null. Their value is the assignment line, which turns a type-contract break into a test failure instead of a downstream consumer's build break.
The Java DSL (and any future language binding) deliberately mirrors the TypeScript connector's structure while staying idiomatic in its own language:
- Same layered split: incoming mapper / outgoing mapper / result hierarchy / response-correlation registry.
- Same semantics: the blame-split error taxonomy, "boundary adapters never
throw", the
"UNDOCUMENTED"error-code sentinel, the maintainer-debug logging protocol (CASE_CONNECTOR_DEBUG, including the same accepted values). - Idiomatic implementation per language: builders +
private final+ unmodifiable collections in Java where TypeScript has spread +readonly;CompletableFuture+ executors where TypeScript has the event loop; exhaustive switch expressions where TypeScript hasUnreachableError.
Rules when touching mirrored code:
- Change both sides or neither. A semantic change to a mapper, result kind, or sentinel value on one side must land on the other side in the same change.
- Mirrored shapes must agree on field order. Positional constructors on the two sides of a wire boundary must list fields in the same order (a swapped pair is a latent, type-checker-invisible bug). Where a language offers named/builder construction at the boundary, prefer it.
- Generated code is marked. Java generated files carry
@Generated("@contract-case/case-definition-generator"); do not hand-edit them — change the generator's DSL declaration data instead. - In thin per-framework adapters (the jest/vitest packages), the sharing
model is: everything real lives in the common package
(
contract-case-dsl-js); the adapter is a small mirror-copy that differs only in framework API calls. Keep the mirrors line-for-line comparable — their similarity is what makes them reviewable.
Existing code that predates a ruling above. Migrate opportunistically (as files are touched), not in a big bang:
- Warning-comment exhaustiveness —
case-coresites that pair adefault:branch with an all-caps "ALL error types must be checked" comment (for exampleentities/results/handlers.ts) should move to theUnreachableError(value: never)pattern where the union allows it. - Missing
readonly— older exported matcher descriptor interfaces (notablycase-entities/src/matchers/definitions.types.ts) lackreadonlymembers. Addreadonlywhen touching these files. - Executor suffix — the HTTP plugin exports executors without the
Executorsuffix (HttpRequestMatcheris an executor, not a DSL class). New executors must use the suffix; existing ones should be renamed when convenient. === undefinedchecks — several sites use=== undefinedwhere== nullwas probably intended (promiseHandler.ts,domain/define.tsincase-connector). Each should be reviewed: change to== null, or keep and add a comment explaining why the null/undefined distinction matters there.