Code Quality & Best Practices
Code quality ensures maintainability, reliability, and better user experience.
Table of Contents
- Remove Sample Code
- Security Best Practices
- Platform Compatibility
- API Usage Best Practices
- Async/Await Patterns
- DOM Helpers
- Deprecated & Replaceable Packages
- Obsidian API Deprecations
- Miscellaneous Rules
Remove Sample Code
Remove Sample Code
Rule: obsidianmd/no-sample-code
Remove all sample/template code before publishing:
- Sample ribbon icons
- Example status bar items
- Template settings
- Boilerplate comments
Rename Sample Class Names
Rule: obsidianmd/sample-names
❌ INCORRECT:
class MyPlugin extends Plugin { }
interface MyPluginSettings { }
class SampleSettingTab extends PluginSettingTab { }
class SampleModal extends Modal { }✅ CORRECT:
class TodoPlugin extends Plugin { }
interface TodoPluginSettings { }
class TodoSettingTab extends PluginSettingTab { }
class TodoModal extends Modal { }Rationale: Rename placeholder class names from the sample plugin template (MyPlugin, MyPluginSettings, SampleSettingTab, SampleModal) to meaningful names for your plugin.
Security Best Practices
Avoid innerHTML and outerHTML
Rule: Security best practice
❌ INCORRECT:
element.innerHTML = '<div>' + userContent + '</div>';
element.outerHTML = '<p>' + text + '</p>';✅ CORRECT:
// Use DOM API
const div = element.createDiv();
div.textContent = userContent;
// Or use Obsidian helpers
const div = createDiv();
div.setText(userContent);Rationale: Using innerHTML/outerHTML is a security risk (XSS vulnerability). Use DOM API or Obsidian helper functions instead.
Platform Compatibility
Avoid Regex Lookbehind
Rule: obsidianmd/regex-lookbehind
❌ INCORRECT:
const pattern = /(?<=@)\w+/; // Not supported on some iOS versions✅ CORRECT:
const pattern = /@(\w+)/;
const match = text.match(pattern);
const username = match?.[1];Rationale: Regex lookbehind not supported on iOS versions before 16.4.
Use Platform API for OS Detection
Rule: obsidianmd/platform
❌ INCORRECT:
if (navigator.platform.includes('Mac')) { }
if (navigator.userAgent.includes('Windows')) { }
if (window.navigator.platform === 'Linux') { }✅ CORRECT:
import { Platform } from 'obsidian';
if (Platform.isMacOS) { }
if (Platform.isWin) { }
if (Platform.isLinux) { }
if (Platform.isMobile) { }
if (Platform.isIosApp) { }
if (Platform.isAndroidApp) { }
if (Platform.isDesktopApp) { }Rationale: Avoid using the navigator API to detect the operating system. Use Obsidian's Platform API instead for better reliability and mobile support.
Use window.setTimeout and window.setInterval
Rule: obsidianmd/prefer-window-timers (named prefer-active-window-timers before v0.4.0)
❌ INCORRECT:
const timer: NodeJS.Timeout = setTimeout(() => {
// do something
}, 1000);
const interval = setInterval(() => {
// do something
}, 1000);
// Also flagged — autofixed to window.setTimeout()
const other: number = activeWindow.setTimeout(() => {
// do something
}, 1000);✅ CORRECT:
const timer: number = window.setTimeout(() => {
// do something
}, 1000);
const interval: number = window.setInterval(() => {
// do something
}, 1000);
// Clear them with:
window.clearTimeout(timer);
window.clearInterval(interval);Rationale: Timers are the one exception to "use activeWindow instead of window". prefer-window-timers flags both bare calls and activeWindow.* calls for setTimeout, setInterval, clearTimeout, clearInterval, and requestAnimationFrame, and autofixes both to window.*. prefer-active-doc deliberately skips window.<timer>(), so the two rules agree. activeWindow follows focus, so a timer scheduled on it could be scheduled on a popout that later closes, and a later activeWindow.clearTimeout() could target a different window than the one that owns the ID. Also use number type instead of NodeJS.Timeout for browser compatibility.
API Usage Best Practices
Don't Use Global app Object
Rule: Best practice from official guidelines
❌ INCORRECT:
// Don't use global app
const vault = app.vault;
const workspace = app.workspace;✅ CORRECT:
// Use the plugin instance reference
const vault = this.app.vault;
const workspace = this.app.workspace;Rationale: Always use this.app from your plugin instance instead of the global app object for better encapsulation and reliability.
Use requestUrl() Instead of fetch()
Rule: Best practice from official guidelines
❌ INCORRECT:
// Don't use fetch()
const response = await fetch('https://api.example.com/data');
const data = await response.json();✅ CORRECT:
import { requestUrl } from 'obsidian';
// Use Obsidian's requestUrl() to bypass CORS
const response = await requestUrl('https://api.example.com/data');
const data = response.json;Rationale: Don't use fetch(). Use Obsidian's requestUrl() instead to bypass CORS restrictions. The browser's fetch API is subject to CORS policies, but requestUrl() bypasses these restrictions.
Type requestUrl() Responses at the Boundary
Rule: Best practice (supports @typescript-eslint/no-unsafe-* and no-any guidance)
RequestUrlResponse.json is typed any in obsidian.d.ts, so reading it lets any leak silently into the rest of the plugin.
❌ INCORRECT:
const response = await requestUrl({ url, throw: false });
const items = response.json.results; // any — no type checking from here on✅ CORRECT:
interface ListingsResponse {
count: number;
results: Listing[];
}
const response = await requestUrl({ url, throw: false });
if (response.status >= 400) {
throw new ApiResponseError(response.status, response.text);
}
// Parse and cast once, in the API layer only
const data = JSON.parse(response.text) as ListingsResponse;
const items = data.results; // Listing[]Rationale: Parse response.text and cast (or validate with a type guard) in one place, the API client. Everything above that layer then gets real types, with no any. Use throw: false so you can inspect status and headers (e.g. retry-after on 429) instead of catching an opaque exception.
Minimize Console Logging
Rule: Best practice from official guidelines
❌ INCORRECT:
async onload() {
console.log('Plugin loaded');
console.log('Processing file:', file.path);
console.log('Settings updated:', settings);
}
onunload() {
console.log('Plugin unloaded');
}✅ CORRECT:
async onload() {
// Only log errors by default
console.error('Failed to process file:', error);
// Use debug mode for development logging
if (this.settings.debugMode) {
console.log('Processing file:', file.path);
}
}
onunload() {
// No console.log in production
}Rationale: The developer console should display errors by default, not debug messages. Minimize unnecessary console output. In production, do not use console.log in onload and onunload - these methods are called frequently and pollute the console. Use debug mode flags for development logging.
Prefer AbstractInputSuggest
Rule: obsidianmd/prefer-abstract-input-suggest
❌ INCORRECT:
// Don't use the custom TextInputSuggest implementation
// (frequently copied from Liam's code)
class MyTextInputSuggest extends TextInputSuggest<string> {
// Uses createPopper with sameWidth modifier
}✅ CORRECT:
import { AbstractInputSuggest } from 'obsidian';
class MyInputSuggest extends AbstractInputSuggest<string> {
getSuggestions(query: string): string[] {
// Return suggestions
}
renderSuggestion(value: string, el: HTMLElement) {
el.setText(value);
}
selectSuggestion(value: string, evt: MouseEvent | KeyboardEvent) {
// Handle selection
}
}Rationale: Use the built-in AbstractInputSuggest API instead of copying custom TextInputSuggest implementations that use createPopper.
Use updateOptions() for Editor Extensions
Rule: Official guidelines
// When reconfiguring editor extensions
this.app.workspace.updateOptions();Rationale: When reconfiguring editor extensions, use updateOptions() to flush changes across all open editors.
Target Main Workspace from Settings (v1.13.0+)
Rule: Multi-window compatibility
As of Obsidian 1.13.0, settings open in a new window instead of a modal. Code using activeDocument from settings callbacks will get the settings window's document, not the main vault window.
❌ INCORRECT (breaks in 1.13.0+):
// In settings tab or settings callback
updateMainUI() {
// activeDocument points to settings window, not main vault!
const container = activeDocument.querySelector('.nav-files-container');
container?.addClass('my-plugin-active');
}✅ CORRECT (works in all versions):
// In settings tab or settings callback
updateMainUI() {
// Always targets the main workspace window
const doc = this.app.workspace.containerEl.ownerDocument;
const container = doc.querySelector('.nav-files-container');
container?.addClass('my-plugin-active');
}When to use which:
- Main workspace UI (nav, sidebar, workspace elements): Use
this.app.workspace.containerEl.ownerDocument - Same-window UI (modal contents, settings elements):
activeDocumentorthis.containerEl.ownerDocumentboth work
Rationale: workspace.containerEl.ownerDocument always points to the main vault window regardless of which window triggered the call. This is backwards compatible with older Obsidian versions.
Async/Await Patterns
Prefer async/await over Promise chains
Rule: Code readability and maintainability
❌ INCORRECT:
function loadData() {
return new Promise((resolve) => {
setTimeout(() => resolve(data), 1000);
});
}
getData()
.then(result => processResult(result))
.then(processed => saveData(processed))
.catch(error => console.error(error))
.finally(() => cleanup());✅ CORRECT:
async function loadData() {
await sleep(1000); // Use Obsidian's sleep() helper
return data;
}
try {
const result = await getData();
const processed = await processResult(result);
await saveData(processed);
} catch (error) {
console.error(error);
} finally {
cleanup();
}Rationale: async/await is more readable and maintainable. Use Obsidian's sleep() function instead of new Promise with setTimeout.
DOM Helpers
Use Obsidian DOM Helpers
Rule: Prefer Obsidian API over vanilla DOM
❌ INCORRECT:
const div = document.createElement('div');
const span = document.createElement('span');
const fragment = document.createDocumentFragment();✅ CORRECT:
// On any HTMLElement:
const div = containerEl.createDiv();
const span = containerEl.createSpan();
const el = containerEl.createEl('section');
// Or use global helpers:
const div = createDiv();
const span = createSpan();
const fragment = createFragment();
// activeDocument → activeWindow (v0.4.1+ autofix):
activeWindow.createEl('p'); // not activeDocument.createEl('p')
activeWindow.createDiv();
activeWindow.createFragment();
// Document-typed variables use .win:
const doc = this.app.workspace.containerEl.ownerDocument;
doc.win.createEl('p'); // not doc.createEl('p')Rationale: Obsidian's helper functions (createDiv(), createSpan(), createEl(), createFragment()) are more concise and integrate better with the API. activeDocument is a Document and doesn't have these helpers — they live on Window/activeWindow. For Document-typed variables, access helpers via the .win property.
Deprecated & Replaceable Packages
The Obsidian community plugin scanner checks for npm packages that have been superseded by Node.js built-ins. Using flagged packages produces warnings that lower your Scorecard score.
Replace builtin-modules with node:module
Rule: Scanner warning — deprecated package
The builtin-modules package provides a list of Node.js built-in module names. Node.js has shipped this natively as module.builtinModules since v9.3.0.
❌ INCORRECT (esbuild.config.mjs):
import builtins from "builtin-modules";
// ...
external: [...builtins],✅ CORRECT (esbuild.config.mjs):
import { builtinModules } from "node:module";
// ...
external: [...builtinModules],Then remove builtin-modules from package.json devDependencies.
General Guidance
When the scanner flags a package with a "should be replaced with an alternative" warning:
- Check es-tooling/module-replacements for the recommended replacement
- Replace the import with the Node.js built-in or recommended alternative
- Remove the flagged package from
package.json - Verify the build still passes
Obsidian API Deprecations
ButtonComponent.setWarning() → setDestructive()
As of Obsidian 1.13+, ButtonComponent.setWarning() is deprecated. Use setDestructive() instead. For destructive primary actions, chain .setDestructive().setCta().
❌ INCORRECT:
new Setting(containerEl)
.addButton(btn => btn.setWarning().setButtonText('Delete'));✅ CORRECT:
new Setting(containerEl)
.addButton(btn => btn.setDestructive().setButtonText('Delete'));Scope: only
ButtonComponent.setWarning()is deprecated.MenuItem.setWarning(isWarning: boolean)is a different, non-deprecated method that takes an argument — don't "fix" it tosetDestructive().
PluginSettingTab.display() → getSettingDefinitions()
display() carries @deprecated Since 1.13.0 in the 1.13 typings, so @typescript-eslint/no-deprecated flags every implementation of it — including plugins that legitimately still need it to support Obsidian below 1.13.0 (see Path B).
You cannot silence this inline. @typescript-eslint/no-deprecated is on the bundled eslint-comments/no-restricted-disable list, so an eslint-disable-next-line for it is itself an error — see Disabling rule X is not allowed. Use a file-scoped override in eslint.config.mjs instead:
{
// Path B: minAppVersion is below 1.13.0, so display() is still the render path.
files: ["src/settings-tab.ts"],
rules: { "@typescript-eslint/no-deprecated": "off" },
}Scope it to the one file, and delete the override the moment you bump minAppVersion to 1.13.0 — otherwise it hides every other deprecation in that file. On Path A the warning is correct and the fix is to delete display().
Note: These deprecations only surface when the
obsidiantypings are current — stale typings silently hide them. Keep"obsidian": "latest"indevDependencies(it's a compile-time-only dependency; the runtime API is provided by the app).
Typings lag the app: the
obsidiannpm package tracks the API surface, not the release train — it can sit a patch or two behind the public desktop build. A1.13.xapp version doesn't guarantee a matching npm version exists.
Miscellaneous Rules
Don't Mutate Defaults with Object.assign
Rule: obsidianmd/object-assign
Avoid calling Object.assign with exactly two arguments where the first argument is a variable with "default" in its name. This mutates the defaults object unexpectedly.
❌ INCORRECT:
// Mutates DEFAULT_SETTINGS - subsequent calls see modified defaults!
Object.assign(DEFAULT_SETTINGS, userSettings);✅ CORRECT:
// Merge into a fresh object - defaults stay intact
Object.assign({}, DEFAULT_SETTINGS, userSettings);
// Or use spread syntax (preferred):
const settings = { ...DEFAULT_SETTINGS, ...userSettings };Rationale: When the target of Object.assign is a variable holding default values (name contains "default"), the defaults object itself is mutated. Pass an empty {} as the first argument or use spread syntax to avoid unexpected side-effects.
Organize Multi-File Plugins into Folders
Rule: Best practice from official guidelines
✅ GOOD STRUCTURE:
my-plugin/
├── src/
│ ├── commands/
│ ├── modals/
│ ├── settings/
│ ├── utils/
│ └── main.ts
├── styles.css
├── manifest.json
└── README.mdRationale: For plugins with multiple files, organize them into folders to improve maintainability and review processes.
Validate manifest.json
Rule: obsidianmd/validate-manifest
Ensure your manifest.json is valid:
{
"id": "unique-plugin-id",
"name": "Plugin Name",
"version": "1.0.0",
"minAppVersion": "0.15.0",
"description": "Short description",
"author": "Your Name",
"authorUrl": "https://...",
"isDesktopOnly": false
}Validate LICENSE
Rule: obsidianmd/validate-license
Your plugin must include a valid LICENSE file. The rule checks two things:
- Copyright holder: The holder must not be "Dynalist Inc." (the Obsidian sample plugin's default). Change it to your name.
- Copyright year: The year must be current. Update it when the year changes.
❌ INCORRECT (unchanged sample plugin LICENSE):
Copyright (C) 2021 by Dynalist Inc.✅ CORRECT:
Copyright (C) 2024 by Your NameRule options:
"obsidianmd/validate-license": ["error", {
currentYear: 2026, // Override the year check (defaults to current year)
disableUnchangedYear: false, // Set true to skip year validation
}]Rationale: The Obsidian sample plugin ships with Dynalist Inc. as copyright holder and an old year. Forgetting to update this before submission is a common mistake.