Production-ready Obsidian plugin patterns: typed settings with migration,
safe vault operations, event auto-cleanup, workspace layout, metadata cache,
and debounced file handlers. Use when hardening a plugin for release,
refactoring for reliability, or learning idiomatic Obsidian TypeScript.
Trigger with "obsidian patterns", "obsidian best practices",
"obsidian production code", "idiomatic obsidian plugin".
Installer avec Codex ou Claude Copiez ce prompt, collez-le dans Codex, Claude ou un autre assistant, puis laissez-le vérifier la page du skill et l'installer pour vous.
Une commande directe contourne le prompt de vérification. Examinez la source avant de l'exécuter.
Production-ready Obsidian plugin patterns: typed settings with migration,
safe vault operations, event auto-cleanup, workspace layout, metadata cache,
and debounced file handlers. Use when hardening a plugin for release,
refactoring for reliability, or learning idiomatic Obsidian TypeScript.
Trigger with "obsidian patterns", "obsidian best practices",
"obsidian production code", "idiomatic obsidian plugin".
Designed for Claude Code, also compatible with Codex and OpenClaw
Obsidian SDK Patterns
Overview
Six production patterns that prevent the most common Obsidian plugin bugs: lost
settings on upgrade, null-reference crashes on deleted files, memory leaks from
unregistered events, stale metadata, and UI jank from rapid file changes. Each
pattern is self-contained and copy-pasteable.
Prerequisites
A working Obsidian plugin (see obsidian-core-workflow-a)
TypeScript strict mode enabled ("strictNullChecks": true in tsconfig)
Familiarity with Plugin.onload() / onunload() lifecycle
Instructions
Step 1: Typed settings with versioned migration
Settings break when you add or rename fields between releases. Version the
settings object and migrate on load so existing users keep their data.
// Merge with defaults to pick up any newly added fields
return
DEFAULTS
Why: Object.assign({}, DEFAULTS, raw) handles new fields added in patch
releases. The explicit migration block handles renames and type changes between
major versions.
Step 2: Safe vault operations (check-before-act)
The Vault API throws if you create a file that exists or read one that was
deleted between your check and your call. Wrap every operation.
// src/vault-helpers.tsimport { App, TFile, TFolder, TAbstractFile, normalizePath } from"obsidian";
exportclassVaultHelper {
constructor(privateapp: App) {}
/** Read file content, return null if file doesn't exist */asyncsafeRead(path: string): Promise<string | null> {
const file = this.app.vault.getAbstractFileByPath(normalizePath(path));
if (!(file instanceofTFile)) returnnull;
returnthis.app.vault.read(file);
}
/** Create or overwrite a file. Creates parent folders as needed. */asyncsafeWrite(path: string, content: string): Promise<TFile> {
const normalized = normalizePath(path);
awaitthis.ensureParentFolder(normalized);
const existing = this.app.vault.getAbstractFileByPath(normalized);
if (existing instanceofTFile) {
awaitthis.app.vault.modify(existing, content);
return existing;
}
returnthis.app.vault.create(normalized, content);
}
/** Append content to a file. Creates the file if it doesn't exist. */asyncsafeAppend(path: string, content: string): Promise<void> {
const normalized = normalizePath(path);
const existing = this.app.vault.getAbstractFileByPath(normalized);
if (existing instanceofTFile) {
const current = awaitthis.app.vault.read(existing);
awaitthis.app.vault.modify(existing, current + content);
} else {
awaitthis.ensureParentFolder(normalized);
awaitthis.app.vault.create(normalized, content);
}
}
/** Delete a file if it exists, moving to trash by default. */asyncsafeDelete(path: string, useTrash = true): Promise<boolean> {
const file = this.app.vault.getAbstractFileByPath(normalizePath(path));
if (!(file instanceofTFile)) returnfalse;
if (useTrash) {
awaitthis.app.vault.trash(file, false);
} else {
awaitthis.app.vault.delete(file);
}
returntrue;
}
/** Ensure a folder (and all parents) exist. */privateasyncensureParentFolder(filePath: string): Promise<void> {
const parts = filePath.split("/");
parts.pop(); // remove filenamelet current = "";
for (const part of parts) {
current = current ? `${current}/${part}` : part;
const existing = this.app.vault.getAbstractFileByPath(current);
if (!existing) {
awaitthis.app.vault.createFolder(current);
}
}
}
}
Step 3: Event management with automatic cleanup
Every this.registerEvent(...) call in onload() is automatically cleaned up
when the plugin unloads. Never use raw addEventListener or app.vault.on()
without registering -- those leak.
exportdefaultclassMyPluginextendsPlugin {
asynconload() {
// File events -- auto-cleaned on unloadthis.registerEvent(
this.app.vault.on("create", (file) => {
if (file instanceofTFile) this.onFileCreated(file);
})
);
this.registerEvent(
this.app.vault.on("delete", (file) => {
if (file instanceofTFile) this.onFileDeleted(file);
})
);
this.registerEvent(
this.app.vault.on("rename", (file, oldPath) => {
if (file instanceofTFile) this.onFileRenamed(file, oldPath);
})
);
// Workspace eventsthis.registerEvent(
this.app.workspace.on("active-leaf-change", (leaf) => {
this.onActiveLeafChange(leaf);
})
);
this.registerEvent(
this.app.workspace.on("layout-change", () => {
this.onLayoutChange();
})
);
// Periodic tasks -- also auto-cleanedthis.registerInterval(
window.setInterval(() =>this.periodicSync(), 60_000)
);
// DOM events -- use registerDomEvent for auto-cleanupthis.registerDomEvent(document, "keydown", (evt: KeyboardEvent) => {
if (evt.key === "F5") this.refreshData();
});
}
// No cleanup code needed in onunload() -- all registered events// are automatically removed by the Plugin base class.
}
Open files in specific panes, split views, and restore layout state.
import { MarkdownView, WorkspaceLeaf } from"obsidian";
exportclassWorkspaceHelper {
constructor(privateapp: App) {}
/** Open a file in a new tab */asyncopenInNewTab(path: string): Promise<void> {
const file = this.app.vault.getAbstractFileByPath(path);
if (!(file instanceofTFile)) return;
const leaf = this.app.workspace.getLeaf("tab");
await leaf.openFile(file);
}
/** Open a file in a vertical split to the right */asyncopenInSplit(path: string): Promise<void> {
const file = this.app.vault.getAbstractFileByPath(path);
if (!(file instanceofTFile)) return;
const leaf = this.app.workspace.getLeaf("split", "vertical");
await leaf.openFile(file);
}
/** Get the currently active markdown file (or null) */getActiveFile(): TFile | null {
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
return view?.file ?? null;
}
/** Iterate all open markdown leaves */forEachOpenNote(callback: (file: TFile, leaf: WorkspaceLeaf) =>void): void {
this.app.workspace.iterateAllLeaves((leaf) => {
if (leaf.viewinstanceofMarkdownView && leaf.view.file) {
callback(leaf.view.file, leaf);
}
});
}
/** Pin/unpin the active tab */togglePin(): void {
const leaf = this.app.workspace.getLeaf();
if (leaf) {
const pinned = (leaf asany).pinned;
(leaf asany).setPinned(!pinned);
}
}
}
Step 5: Metadata cache for fast queries
metadataCache is Obsidian's pre-parsed index of all vault files. It avoids
reading file content for frontmatter, tags, links, and headings.
import { App, TFile, CachedMetadata } from"obsidian";
exportclassMetadataHelper {
constructor(privateapp: App) {}
/** Get parsed metadata for a file (frontmatter, tags, links, headings) */getCache(file: TFile): CachedMetadata | null {
returnthis.app.metadataCache.getFileCache(file);
}
/** Get frontmatter value, returns undefined if missing */getFrontmatterValue(file: TFile, key: string): any | undefined {
const cache = this.getCache(file);
return cache?.frontmatter?.[key];
}
/** Find all files with a specific tag */filesWithTag(tag: string): TFile[] {
const normalized = tag.startsWith("#") ? tag : `#${tag}`;
returnthis.app.vault.getMarkdownFiles().filter((file) => {
const cache = this.getCache(file);
// Tags in bodyconst bodyTags = cache?.tags?.map((t) => t.tag) ?? [];
// Tags in frontmatterconst fmTags = (cache?.frontmatter?.tags ?? []).map((t: string) =>
t.startsWith("#") ? t : `#${t}`
);
return [...bodyTags, ...fmTags].includes(normalized);
});
}
/** Get all outgoing links from a file */outgoingLinks(file: TFile): string[] {
const cache = this.getCache(file);
const links = cache?.links?.map((l) => l.link) ?? [];
const embeds = cache?.embeds?.map((e) => e.link) ?? [];
return [...newSet([...links, ...embeds])];
}
/** Get files that link to this file (backlinks) */backlinks(file: TFile): TFile[] {
const resolved = this.app.metadataCache.resolvedLinks;
constresults: TFile[] = [];
for (const [sourcePath, targets] ofObject.entries(resolved)) {
if (file.pathin targets) {
const source = this.app.vault.getAbstractFileByPath(sourcePath);
if (source instanceofTFile) results.push(source);
}
}
return results;
}
/** Wait for metadata cache to be fully indexed (useful on plugin load) */onCacheReady(callback: () =>void): void {
if (this.app.metadataCache.initialized) {
callback();
} else {
this.app.metadataCache.on("initialized", callback);
}
}
/** Listen for metadata changes on a specific file */onFileMetadataChange(
plugin: Plugin,
filePath: string,
callback: (cache: CachedMetadata) =>void
): void {
plugin.registerEvent(
this.app.metadataCache.on("changed", (file, _data, cache) => {
if (file.path === filePath) callback(cache);
})
);
}
}
Step 6: Debounced file modification handlers
Plugins that react to file changes (auto-save, indexing, sync) fire too often
without debouncing. Obsidian's vault modify event fires on every keystroke
when live preview is active.
import { Plugin, TFile, debounce } from"obsidian";
exportdefaultclassIndexerPluginextendsPlugin {
// Debounce: wait 2s after last modification before processingprivate processFile = debounce(
async (file: TFile) => {
console.log(`[Indexer] Processing ${file.path}`);
const content = awaitthis.app.vault.read(file);
awaitthis.updateIndex(file, content);
},
2000,
true// true = reset timer on each call (trailing edge)
);
asynconload() {
this.registerEvent(
this.app.vault.on("modify", (file) => {
if (file instanceofTFile && file.extension === "md") {
this.processFile(file);
}
})
);
}
privateasyncupdateIndex(file: TFile, content: string): Promise<void> {
// Your indexing logic here -- runs at most once per 2s per file
}
}
For per-file debouncing (different timers for different files):