Type-Safety Pass — Eliminated All `any` Declarations, Retired Hand-Rolled YAML Parser, Gated ESLint Into Build
Five-commit refactor that brings the project from 14 explicit `any` declarations to zero, replaces a structurally broken regex-based YAML parser with Obsidian's native frontmatter APIs (metadataCache.getFileCache + fileManager.processFrontMatter), introduces a small set of boundary coercers for narrowing untrusted values, and wires an ESLint flat-config mirroring ObsidianReviewBot's rules into `pnpm build` so the rules that gate community-store submission now fire locally pre-push. Verified working in a live Obsidian vault.
Why Care?
Cite-Wide was rejected from the Obsidian community plugin store for "lack of
type safety" — concretely, ObsidianReviewBot's @typescript-eslint/no-explicit-any
fired on the project's 14 explicit any declarations. This refactor closes
out that rejection criterion. The plugin now produces zero violations of
the rules the bot actually enforces, and those same rules now run locally as
part of pnpm build, so the next surprise on resubmission shouldn't be a
type-safety one.
Two side benefits surfaced once the audit got specific about where the
anys were:
The hand-rolled YAML parser at
citationFileService.ts:404was structurally broken. It split key frontmatter lines on the first:, which mangled every URL value (every URL contains://). The reads happened to work because the malformed values would later get unquoted byunescapeYamlValueand end up looking-vaguely-correct, but any frontmatter where a quoted URL had no surrounding quotes would silently corrupt. Replacing the parser with Obsidian's native APIs is both a type-safety fix and a real correctness fix.The Obsidian-API shim at
src/types/obsidian.d.tswas actively lying rather than merely redundant — it tightenedMarkdownView.file: TFile | null → TFileandPluginManifest.dir?: string → string, hiding null cases the upstream d.ts wants the caller to handle. Deleting the shim restores those constraints. TheApp.commands: anyaugmentation it also carried was never accessed by any code path; pure dead weight.
The reason this is recorded rather than left to commit messages: the
strategic content of this work isn't "we removed 14 lines of any," it's
"we adopted a boundary-coercion strategy" (untrusted values are typed
unknown and narrowed via small coercer functions; types inside the
codebase remain strict). That strategy is the load-bearing pattern for any
future code touching frontmatter, JSON responses, or undocumented Obsidian
APIs, and it lives in context-v/reminders/Obsidian-Type-Safety.md for
posterity.
What Was Built
Commits in order
| # | Commit | Title |
| 1 | 28b7b83 | add(utils): boundary coercers for unknown→typed conversion |
| 2 | 978984a | fix(types): remove easy any sites; delete redundant Obsidian shim |
| 3 | 17193a2 | refactor(citations): retire hand-rolled YAML; coerce frontmatter at boundary |
| 4 | b6a24dc | fix(types): narrow Jina.ai Reader response with isRecord + coercers |
| 5 | 0a5aa13 | add(lint): eslint flat-config mirroring Obsidian review-bot rules |
src/utils/coerce.ts — the boundary coercers (commit 1)
Five small, non-throwing helpers that take unknown and return well-typed
values:
isRecord(v): v is Record<string, unknown>— type predicate for objectsasString(v): string | undefined— strings pass through; numbers/booleans stringifiedasNumber(v): number | undefined— numbers pass through; numeric strings parsedasStringArray(v): string[]— arrays mapped through asString and filtered; scalars promoted to[scalar]asDate(v): string | undefined— coerced to ISO string viaDate, returns undefined on Invalid Date
Lossy on purpose: garbage input returns undefined / [] rather than
throwing, so a content creator's malformed frontmatter never crashes the
plugin. This is the load-bearing piece — every later commit consumes
these.
"Easy" any removals (commit 2)
| Site | Before | After |
main.ts:541 | constructor(app: any, existingCitation: any, ...) | constructor(app: App, existingCitation: CitationMetadata, ...) — Obsidian's Modal constructor is already typed (app: App); the widening was gratuitous |
src/utils/logger.ts ×6 | details?: any on LogEntry, addEntry, error, warn, info, debug | details?: unknown everywhere; body already narrows via details instanceof Error, so no functional change |
src/services/cleanReferencesSectionService.ts:40 | const editor = (window as any).activeEditor?.editor; (in processSelection()) | Deleted — processSelection() was never called from any code path; addColonSyntaxWhereNone is the only consumed export |
src/types/obsidian.d.ts | Whole file | Deleted (see Why Care? §2 above) |
citationFileService.ts rewrite (commit 3)
The biggest single change. Net 218 inserts / 504 deletes because
delegating to Obsidian's APIs collapses a lot of hand-rolled scaffolding.
| Concern | Was | Now |
| Singleton init | new CitationFileService(null as any) + later patch via (svc as any).app = app | No-arg constructor; private _app: App | null = null; private getter app that throws if uninitialized; public setApp(app) setter |
| Frontmatter read | parseFrontmatter() regex parser splitting on first : per line, returning Partial<CitationMetadata> via (metadata as any)[key] | app.metadataCache.getFileCache(file)?.frontmatter, narrowed via isRecord, each field run through the coercers |
| Frontmatter write | createFrontmatterString() hand-emitter using a 30-char-blacklist for quoting decisions, plus regex replace of the --- block in file content | app.fileManager.processFrontMatter(file, fn) for both new files and updates; framework handles parse/mutate/serialize atomically |
updateCitationUsage | ~50 lines of read + parse + reconstruct + regex-replace + write | ~10 lines via processFrontMatter callback |
Folder traversal (getAllCitationFiles) | processFolder = (folder: any) => folder.children.forEach((child: any) => ...) | Recursive walk using instanceof TFolder / instanceof TFile narrowing; no casts |
citationData parameters | citationData: any on createCitationFileWithData and createFrontmatterFromData | Typed CitationData (the actual runtime shape, imported from urlCitationService.ts) |
getCitationMetadata / findCitationByUrl / getCitationText / getAllCitationFiles | All declared async even though the underlying Obsidian APIs are sync | Now sync; two await keywords dropped at call sites in main.ts |
Jina.ai response narrowing (commit 4)
The final any in the project was parseReaderResponse(data: any, ...).
Now data: unknown, narrowed via isRecord (handling both the
{ data: {...} } wrapper and flat-at-root variants the API actually
returns), with every leaf field through asString. Side fix: the
existing try/catch around date parsing was inert (new Date(badInput)
yields an Invalid Date object, not an exception); replaced with
Number.isNaN(dateObj.getTime()). Also extracted the duplicated
fallback-citation construction into a private fallbackCitation()
helper.
ESLint flat-config (commit 5)
Closes the local-enforcement gap surfaced in the audit: the project had
@typescript-eslint/{eslint-plugin,parser} installed but no config,
meaning the rules that gate community-store submission only ran on
ObsidianReviewBot, post-PR.
eslint.config.mjs:
Type-aware via
parserOptions.project: './tsconfig.json'Mirrors the four bot rules:
no-explicit-any,no-unnecessary-type-assertion,no-floating-promises,no-base-to-stringreportUnusedDisableDirectives: 'error'— suppression-via-disable fails the build, per the bot's postureIgnores
main.js,node_modules,examples,*.mjs
package.json:
New
pnpm lintscript for ad-hoc runspnpm buildnow runstsc -noEmit -skipLibCheck && eslint . && esbuild production, so lint fires before any artifact is produced
The new rule flagged exactly one floating promise (CitationModal.ts:105,
this.convertCitationGroup(group) in a click handler); fixed via the
void operator per the bot-acceptable pattern. Stale .eslintignore
file removed (flat config uses inline ignores; ESLint 9 emits a deprecation
warning for the file).
Verification
pnpm build exits clean: tsc -noEmit -skipLibCheck ✓, eslint . ✓,
esbuild production ✓. grep -r ': any\|<any>\|as any' src/ main.ts
returns zero hits.
The plugin was reloaded in a live Obsidian vault and exercised end-to-end:
extract-from-URL (Jina path), convert-numeric-footnotes-to-hex,
duplicate-citation detection (the path that exercises the rewritten
findCitationByUrl + getCitationMetadata). All worked.
What Changed in Approach (the meta-lesson)
| Previous mode | This mode |
Reach for any when external data shape is unclear | Type as unknown, narrow with isRecord + small coercer functions |
Use as any to bypass private/initialization-order issues | Solve initialization order honestly with a lazy getter |
| Hand-roll YAML parsing, quoting, and emission | Use app.metadataCache.getFileCache (read) and app.fileManager.processFrontMatter (read+write), pipe through coercers |
| Augment Obsidian's d.ts whenever a private API is needed | Question whether the private API is needed at all (most usage in this repo had documented public equivalents); when augmentation is genuinely required, type as unknown not any |
| Treat lint as a thing to set up "later" | Wire lint into pnpm build from the start so the rules that gate community-store submission fire locally pre-push |
Generalizing beyond cite-wide: any in a TypeScript codebase is almost
always solving the wrong problem. The right problem is "I don't know
the shape of this value." The right tool is unknown + narrowing, not
any + hope. The 14 sites the audit found mapped 1:1 onto five recurring
patterns (untyped framework callback param, untyped logger payload, JSON
response, dynamic dictionary access, and singleton init), each of which
has a textbook-clean alternative.
Open Items
Unused dependencies still flagged.
fastify,@modelcontextprotocol/sdk, andzodremain inpackage.jsonwith zero imports. The type-safety pass didn't introduce uses for them. Decision pending: confirm and remove (saves bundle size and removes thezod 3 → 4deferred-major question entirely), or wire them into a feature.Major dependency bumps still held back as of 2026-05-01:
typescript 5.8 → 6,eslint 9 → 10,zod 3 → 4,@types/node 22 → 25(the last intentionally pinned). See2026-05-01_01.md. The eslint bump is now extra-relevant since we have a real config that depends on it.Metadata-cache freshness on newly-created files.
getCitationMetadatareads fromapp.metadataCache.getFileCache, which is populated asynchronously via themetadataCache:changedevent. Right after creating a citation file, the cache may not yet reflect it, so a duplicate-detection check fired in the same tick could miss. Not observed during smoke testing, but worth flagging for any future rapid-fire workflow.styles.cssworking-tree change from before this work began. Previously surfaced as a minified-but-equivalent version of the committed file. Untouched by this pass; resolution deferred until upcoming style work.Resubmission to the Obsidian community store is unblocked by this pass, but not yet scheduled. The next concrete step would be running the actual ObsidianReviewBot checks against the current branch (it can be invoked via the
obsidianmd/obsidian-releasesPR flow) before opening a real submission, to catch any rules our local config doesn't yet mirror.The bumped semantic version (0.0.1.1 → 0.0.1.2) is recorded in
package.jsononly;manifest.jsonandversions.jsonwere not resynced viapnpm version. If a release is intended, run that script before tagging.
Files Touched
cite-wide/
├── package.json (modified: lint scripts; version 0.0.1.1 → 0.0.1.2)
├── eslint.config.mjs (created: flat-config mirroring bot rules)
├── .eslintignore (deleted: superseded by flat-config inline ignores)
├── main.ts (Modal constructor types; 2 redundant awaits dropped)
├── src/
│ ├── utils/
│ │ ├── coerce.ts (created: 5 boundary coercers)
│ │ └── logger.ts (any→unknown ×6 sites)
│ ├── services/
│ │ ├── citationFileService.ts (rewritten — singleton, YAML, folder walk; 218+ / 504−)
│ │ ├── urlCitationService.ts (Jina parse narrowed to unknown + isRecord + coercers)
│ │ └── cleanReferencesSectionService.ts (deleted dead processSelection())
│ ├── modals/
│ │ └── CitationModal.ts (one floating promise → void operator)
│ └── types/
│ └── obsidian.d.ts (deleted entirely — partially redundant, partially lying)
└── changelog/ (originally created at context-v/changelogs/; relocated to repo-root changelog/ on 2026-05-17)
└── 2026-05-01_02.md (created — this file) Reference
Strategy doc this implements:
context-v/reminders/Obsidian-Type-Safety.mdAudit reports that informed the work: captured in conversation; 14
anysites across 6 files, plus the hand-rolled-YAML and shim findingsPrior changelog (deps refresh):
changelog/2026-05-01_01.md(file relocated 2026-05-17 fromcontext-v/changelogs/to the repo-standardchangelog/location)ObsidianReviewBot rule sources:
https://typescript-eslint.io/rules/no-explicit-any/, plus example PRs athttps://github.com/obsidianmd/obsidian-releases/pull/8131,…/9166,…/10160,…/10723Obsidian APIs adopted:
App.metadataCache.getFileCache(file).frontmatterandApp.fileManager.processFrontMatter(file, fn), both documented inobsidian.d.ts(lines 4272 and 2830 inobsidian@1.12.3)