← Back to Blog

How to Detect and Enforce Error Handling Conventions in a TypeScript Codebase

By Nark Team

To find inconsistent error handling in a TypeScript codebase, run a tool that mines your existing patterns instead of comparing your code against a generic best-practice template. Convention mining groups every callsite of a given package and method (e.g. every axios.get), measures which protection idiom — try-catch:direct, try-catch:wrapped, .catch():typed, or framework error middleware — appears at the most sites, and recommends that pattern as the fix for the violations in the same group. Nark ships this as a deterministic post-scan pass: if ≥3 callsites use the same idiom and that idiom accounts for ≥60% of the group, the recommendation appears on every violation in the group, with up to five supporting callsites cited inline as evidence.

Quick Answer: Run npx nark --tsconfig ./tsconfig.json from a TypeScript repo root. Every violation Nark reports includes a conventionMatch block when ≥3 in-repo callsites agree on a single protection idiom by ≥60% majority. The recommended pattern is mined from your code, not pulled from an external best-practice list, and the supporting callsites are listed by file and line so you can copy the exact shape your codebase already uses.


Why Do TypeScript Codebases Drift into Inconsistent Error Handling?

Error handling style is rarely written down. A team picks try-catch around axios.get in week one, someone else uses .then().catch() in week three because they pasted from Stack Overflow, a third dev wraps everything in a withErrorBoundary() helper in week six because they were copying a React pattern, and by month six the codebase has four different shapes for the same kind of failure.

The drift is invisible because every individual choice is locally reasonable. The compiler accepts all four shapes. ESLint accepts all four shapes (none of them is a floating promise). Tests pass because each pattern catches errors, just differently. The bug surface only emerges when a new engineer has to add a fifth axios.get and asks "what do we do here?" — and the answer depends on which file they're in.

Three forces compound the drift:

  1. AI code suggestions hallucinate the shape. GPT-4 and Claude will write try-catch around an axios.get even if the rest of your codebase uses .catch():typed, because they're trained on the global average, not your repo.
  2. Linters can't read taste. ESLint's @typescript-eslint/no-floating-promises enforces "the promise must be handled." It cannot enforce "the promise must be handled the way the other 27 sites in this codebase handle it."
  3. Code review fatigue. A reviewer who sees the fifth different pattern in six months stops flagging it. The convention erodes one PR at a time.

By the time the pattern is "everywhere is different," fixing it requires reading every callsite, picking a canonical shape, and rewriting the outliers. That's exactly the work convention mining automates.


Why Don't Linters or AI Assistants Solve This?

The standard answers fall into three buckets, and each has a clear gap.

ESLint and typescript-eslint ship rules like no-floating-promises, no-misused-promises, and no-throw-literal. These catch unhandled promises and a small set of error-handling bugs. They have no concept of "this codebase's preferred shape." The rules are universal, not repo-specific. You can write custom ESLint rules to enforce a shape, but you have to know the shape first — and writing a custom AST rule per package is the work convention mining replaces.

TypeScript strict mode catches type errors. It does not catch missing error handling. There is no throws clause in TypeScript's type system, so the compiler has no way to know that axios.get can throw AxiosError or that prisma.user.create can throw PrismaClientKnownRequestError. (See What Static Analysis Tools Check for Missing Error Handling in TypeScript for the long version.)

AI code reviewers like CodeRabbit, Copilot, or Cursor will flag missing error handling, but they hallucinate the fix. Without grounding in your repo's actual patterns, they suggest a generic try-catch block — even when your codebase uses a typed .catch() handler everywhere else. The fix is plausible-looking, syntactically valid, and inconsistent with the rest of your code. Reviewers approve it. Drift continues. This is the failure mode covered in Why AI-Generated TypeScript Code Skips Error Handling.

What's missing in all three is a deterministic signal: what does THIS codebase actually do? That signal can only be answered by reading the codebase. Convention mining is the read.


What Is Convention Mining?

Convention mining is a second-pass static analysis step that aggregates passing callsites and violations into (package, postcondition) groups, then attaches a fix recommendation to each violation if the group meets two thresholds:

  • Minimum site count: ≥3 callsites in the group (so 1/1 = 100% doesn't count as a convention).
  • Minimum majority ratio: ≥60% of those sites use the same protection idiom (so a 50/50 split doesn't produce a recommendation).

If both thresholds pass, the miner picks the top idiom, attaches a conventionMatch block to every violation in the group, and lists up to five supporting callsites as evidence. If either threshold fails, the violation ships with no recommendation — absence carries signal. The miner refuses to recommend a fix when the in-repo data is too weak to support one.

The whole pass is deterministic. No LLM. No language model. No "based on industry best practices." The only inputs are the AST you already gave the scanner and the matcher signatures the scanner already produced. Run the scan twice and you get the same output. The full algorithm is around 300 lines of pure function in src/v2/core/convention-miner.ts inside Nark and runs in O(N+V) time where N is the number of passing sites and V is the number of violations.


How Does Nark Detect In-Repo Error Handling Patterns?

Nark's scanner walks the TypeScript AST and matches each callsite against the Nark Profile for the imported package. A Nark Profile is a YAML declaration of what a package can throw and which idioms protect against it. For example, the axios Profile declares postconditions like axios.error-handling.try-catch-required with five known protection matchers:

Matcher IDWhat it recognizes
try-catch:directtry { await axios.get(...) } catch { ... } directly around the call
try-catch:wrappedThe call is inside a function whose body is wrapped in a try-catch
.catch():typedaxios.get(...).catch((e: AxiosError) => ...) chained on the promise
framework:express-async-errorsThe handler is registered with Express and express-async-errors is imported
framework:react-queryThe call lives inside a useQuery / useMutation whose onError is defined

When the scanner finds a callsite that does match one of these idioms, it records a passedDetection against the postcondition. When it finds a callsite that matches none, it records a Violation. The convention miner runs after every file has been walked, pools the passes and the violations by (package, postcondition), and computes the histogram of matcher IDs in each group.

Here's the actual pattern Nark records for a passing site:

import axios from 'axios';

async function getUsers() {
  try {
    const response = await axios.get<User[]>('/api/users');
    return response.data;
  } catch (error) {
    if (axios.isAxiosError(error)) {
      throw new ApiError(error.message);
    }
    throw error;
  }
}
// Nark records: passedDetection { matcherId: 'try-catch:direct',
//                                 package: 'axios',
//                                 postcondition: 'error-handling.try-catch-required',
//                                 file: 'src/api/users.ts', line: 5 }

And here's a violation in the same file:

import axios from 'axios';

async function getMetrics() {
  // No try-catch. Nark records this as a Violation.
  const response = await axios.get<Metrics>('/api/metrics');
  return response.data;
}

If the rest of the file (and the rest of the repo) uses try-catch:direct, the violation gets a conventionMatch pointing back at the matching pattern.


Real Example: 28 Axios Callsites, One Out of Step

Here's the result of running Nark v1.0 against a real Next.js + Prisma + Stripe + Clerk + Sentry codebase — one of our own apps.

In one file, Nark found 28 axios callsites covered by the same postcondition. The breakdown:

package:        axios
postcondition:  error-handling.try-catch-required
site_count:     28
top matcher:    try-catch:direct
match_ratio:    0.964   (27 of 28 sites)

27 of the 28 sites use try-catch:direct. The 28th uses a .then().catch() chain — locally valid, but inconsistent with the rest of the file. Nark emits the violation with this conventionMatch block attached:

{
  "pattern_id": "try-catch:direct",
  "site_count": 28,
  "match_ratio": 0.964,
  "supporting_sites": [
    { "file": "src/lib/api/users.ts",   "line": 12 },
    { "file": "src/lib/api/orders.ts",  "line": 8  },
    { "file": "src/lib/api/billing.ts", "line": 24 },
    { "file": "src/lib/api/sync.ts",    "line": 19 },
    { "file": "src/lib/api/webhook.ts", "line": 31 }
  ],
  "miner_version": "v1.0"
}

The fix is now mechanical. The developer reads any of the five supporting callsites, copies the shape, applies it to the violation. The Profile said "this can throw"; the convention miner said "this is how YOUR codebase already protects against it." Both answers are grounded in evidence, neither comes from a model.

If a different group — say, axios calls inside React Query hooks — were dominated by framework:react-query instead, the miner would have recommended that pattern for violations in that group. The recommendation is local to the group, not global to the package. A repo can use try-catch:direct for backend code and framework:react-query for frontend hooks, and the miner will recommend each pattern in its correct context.


What Does the Evidence Trail Look Like?

Every Nark v1.0 violation carries two new blocks in its JSON output: detectionTrace and conventionMatch. Together they answer the question "why did Nark flag this?" with a per-matcher audit log.

{
  "file": "src/lib/api/metrics.ts",
  "line": 14,
  "package": "axios",
  "postcondition": "error-handling.try-catch-required",
  "severity": "error",
  "detectionTrace": [
    { "matcher": "try-catch:direct",                "result": "failed" },
    { "matcher": "try-catch:wrapped",               "result": "failed" },
    { "matcher": ".catch():typed",                  "result": "failed" },
    { "matcher": "framework:express-async-errors",  "result": "not_applicable",
      "reason": "no express handler in scope" },
    { "matcher": "framework:react-query",           "result": "not_applicable",
      "reason": "not inside useQuery or useMutation" }
  ],
  "conventionMatch": {
    "pattern_id": "try-catch:direct",
    "site_count": 28,
    "match_ratio": 0.964,
    "supporting_sites": [ /* up to 5 */ ],
    "miner_version": "v1.0"
  }
}

detectionTrace lists every matcher Nark evaluated and its outcome — passed, failed, or not_applicable with a reason. A reviewer (or a compliance auditor) can read the trace and verify exactly what was checked. There is no "the AI thinks this is wrong" black box. The trace is the audit artifact: if the scanner evaluated five matchers and four were not applicable and one failed, the trace says so explicitly.

For SOC 2 and ISO 27001 evidence collection, this matters. A compliance buyer needs a deterministic, reproducible record of what was scanned and what was flagged. A trace produced by a model is unfalsifiable — re-run the model and the trace might change. A trace produced by a deterministic walk is the same on every run, and the evidence is the trace itself.


How Does Convention Mining Compare to Linters and AI Code Review?

The TypeScript safety stack has well-defined layers, each owned by a different tool. Convention mining adds a layer the existing tools don't cover:

┌──────────────────────────────────────────────────────────┐
│  Type errors          →  TypeScript strict mode          │
│  Universal anti-patterns →  ESLint / typescript-eslint   │
│  Security findings    →  Semgrep / CodeQL                │
│  Package failure modes →  Nark Profiles                  │
│  In-repo convention drift →  Convention mining (Nark)    │
└──────────────────────────────────────────────────────────┘
ApproachWhat it knowsWhat it recommendsDeterminism
ESLint custom ruleThe rule you wroteThe shape the rule enforcesDeterministic, but you write each rule by hand
AI code reviewer (CodeRabbit, Copilot)Global training dataA plausible-looking fix from anywhere on GitHubNon-deterministic — same input, different output
TypeScript strict modeType informationType-level fixes onlyDeterministic, but blind to runtime errors
Nark convention miningYour codebase's passing callsitesThe exact idiom your codebase uses, with citationsDeterministic; same input always produces same output

The substantive difference is the grounding source. Linters ground in rules you wrote. AI reviewers ground in everything they've seen. TypeScript grounds in types. Convention mining grounds in your own codebase. When the recommendation says "do what these five other sites do," the fix matches the surrounding code by construction.


Try Convention Mining on Your Codebase

npx nark --tsconfig ./tsconfig.json --output ./nark-scan.json

Then look for the conventionMatch block on any violation in the output. Every block lists:

  • pattern_id — the protection idiom your codebase uses for this (package, postcondition) group
  • site_count — how many callsites Nark grouped together
  • match_ratio — the share of those sites using the recommended idiom (≥0.60 for a recommendation to appear)
  • supporting_sites — up to five concrete file:line references you can copy from

Nark ships with 160+ Nark Profiles covering axios, Prisma, Stripe, Clerk, Sentry, and most of the common runtime packages a TypeScript SaaS uses. The convention miner runs on every scan; you don't need to configure it. If your group meets the thresholds, the recommendation appears. If it doesn't, no recommendation — the silence itself is the signal that your codebase doesn't have a convention worth enforcing yet.

The 22 lines of trace that ship with every violation are the audit artifact. The recommendation that ships with most violations is the fix. Neither was inferred by a model. Both were measured from your code.


Where Convention Mining Is Heading

The v1.0 release ships the deterministic data layer: every scan produces the detectionTrace and conventionMatch blocks, the SaaS persistence schema records them, and the telemetry loop measures whether recommendations get adopted. What that data powers next is rendering — turning the JSON into something a developer reads at the moment they make the fix.

Expect convention-match recommendations to surface in the npx nark terminal output, on public scan share pages, inside the Nark dashboard's violation views, and as inline comments on pull requests via the Nark GitHub PR bot. The same conventionMatch block also feeds the /nark-fix AI assistant skill so an LLM acting on a violation can ground its fix in the in-repo pattern instead of inventing one.

The infrastructure ships today. Each surface lands on top of it.


Frequently Asked Questions

What's the difference between an ESLint rule and a codebase convention?

An ESLint rule is a universal pattern the rule author wrote — it enforces "no floating promises" regardless of which codebase it runs on. A codebase convention is the pattern your repo actually uses for a given operation, measured from your existing callsites. The rule is prescriptive (someone decided it); the convention is descriptive (your team did it). Convention mining inverts the linter model: instead of you teaching the tool the rule, the tool reads the rule from your code.

Is convention mining the same as drift detection?

Yes. Every violation that ships with a conventionMatch block IS a drift signal — the in-repo majority is the convention, and the violation is the callsite that drifted from it. The thresholds (≥3 sites, ≥60% majority) define when the signal is strong enough to call drift; below them, the miner stays silent. A codebase where most violations come back with a recommendation is one with strong conventions and a few outliers. A codebase where most violations come back without one hasn't settled on a pattern yet — and that's the answer too.

Does the convention miner ever recommend a pattern from outside my codebase?

No. The miner reads passedDetections collected by the scanner during the same run. The only candidate patterns are the matcher IDs the scanner observed at passing sites in your repo. If your codebase has zero passing sites for a given (package, postcondition) group, no recommendation can appear.

What thresholds does the miner use?

minSites = 3 and minRatio = 0.60 in Nark v1.0. The thresholds are exposed as DEFAULT_OPTS in the convention miner source and can be overridden by configuration in a future release. The v1.0 defaults were chosen so that "1 of 1" and "2 of 2" don't produce recommendations (degenerate majority) and "3 of 5" doesn't either (50/50 isn't a convention).

What happens when there's no clear majority?

The violation ships with no conventionMatch block. Absence is the signal that the codebase is genuinely split. The fix is a human decision: pick the canonical shape, refactor outliers, then Nark's next scan will recognize the new majority and recommend it automatically.

Can the miner handle multiple correct patterns?

Yes, because it groups by (package, postcondition), not by package alone. A repo can use try-catch:direct for backend axios calls and framework:react-query for frontend hooks, and each group produces its own recommendation. The grouping is automatic — the postcondition is part of the Profile, not something you configure.

Why does this need to ship in the scanner instead of the IDE?

Because the miner is a second pass over the whole scan result. It needs to see every file's callsites before it can compute the histogram. An IDE plugin would only see one file at a time, which makes the in-repo majority signal impossible to compute. The miner runs once per npx nark invocation, after every file has been analyzed, and adds well under 5% to total scan time even on large repos.

Where's the closed-loop measurement?

Every recommendation Nark emits fires a conventionMatch_rendered telemetry event. When a subsequent scan shows the violation has disappeared, Nark's SaaS pipeline emits a resolved_with_recommended_pattern event. The pair lets us measure whether developers actually adopt the recommended pattern — an empirical signal we use to tune the thresholds and add new matchers. Telemetry is opt-out at NARK_TELEMETRY=off.


Run It

npx nark --tsconfig ./tsconfig.json

Nark scans your TypeScript project against 160+ Nark Profiles, traces every postcondition it evaluates, mines your in-repo error handling conventions, and recommends fixes from your own callsites. Deterministic, evidence-backed, zero LLM inference. The trace is the audit artifact and the recommendation is grounded in your code, not in industry best practices.