What I Learned Building My First VSCode Extension

I just published my first extension for Visual Studio Code — a language server that adds IntelliSense support for the Obsidian dependency injection framework. In this post, I’ll share some tips and insights I picked up along the way. 1. From Yeoman to AI: Faster Project Scaffolding When I used to start new projects, I’d always search for a Yeoman template first. For those unfamiliar, Yeoman is a scaffolding tool that helps you create new projects using community-made generators. The idea was great, but in practice, many generators were unmaintained and often produced broken or outdated code. LLMs have made this problem obsolete — now you can spin up a working project in minutes with a simple prompt. No need to get fancy; even something like this gets the job done: I want to write a VSCode extension that provides completions and go to definition. Use TypeScript, Jest, and ts-morph. Bundle with esbuild. 2. Traverse the AST like a pro with ts-morph TypeScript’s compiler API lets you query, traverse, and manipulate your code’s Abstract Syntax Tree (AST) — but it’s low-level and often cumbersome to use. Fortunately, the community-built ts-morph library offers a well-maintained alternative. It builds on the compiler API with a friendlier, more intuitive interface and includes helpful utilities that make working with the AST much smoother and significantly improve developer experience. To illustrate the difference, here’s how you would add an import statement using TypeScript’s compiler API — a verbose, two-step process: import * as ts from "typescript"; // Step 1: Create the new import declaration const importDecl = ts.factory.createImportDeclaration( undefined, // decorators undefined, // modifiers ts.factory.createImportClause( false, // isTypeOnly undefined, // default import ts.factory.createNamedImports([ ts.factory.createImportSpecifier( false, // isTypeOnly undefined, // property name ts.factory.createIdentifier("MyClass") // local name ) ]) ), ts.factory.createStringLiteral("./my-class") ); // Step 2: Insert the import into the source file const updatedSourceFile = ts.factory.updateSourceFile( sourceFile, [importDecl, ...sourceFile.statements] ); Now here’s the same task with ts-morph: sourceFile.addImportDeclaration({ namedImports: ["MyClass"], moduleSpecifier: "./my-class", }); No factories, no manual AST construction — ts-morph handles the boilerplate for you and lets you focus on what you actually want to do. 3. Handling Extended and Composite tsconfigs with ts-morph After covering the codebase with a solid suite of integration tests, I felt confident the extension worked and was ready to try it on a real project. I packaged and installed it — but to my surprise, it immediately crashed. Digging into the stack trace, I discovered that ts-morph wasn’t able to read any TypeScript files in the project. This was unexpected: I had explicitly passed a reference to the project’s tsconfig.json when initializing ts-morph, assuming it would correctly index all source files. As it turns out, ts-morph doesn’t fully support tsconfig setups with multiple layers of configuration. In my case, the project used a composite tsconfig that referenced other config files. These referenced configs contained the actual include and files properties that define which files are part of the project. Similarly, ts-morph doesn’t handle config files that use the extends property to inherit settings from a base config. In both cases, ts-morph doesn’t merge the configurations, which means it ends up missing large parts of the source code. This limitation has been reported and acknowledged by the author, but as of now, there’s no built-in solution. To work around this, I wrote a utility class that manually parses and merges all the relevant tsconfig files before passing the final config to ts-morph. You can find the result here. Later, I also discovered an open source tool called tsconfck that’s dedicated to solving exactly this problem. 4. Keeping ts-morph in Sync with Editor Changes One thing to be aware of when using ts-morph is that it reads TypeScript files once and then keeps them in memory. This design improves performance — reading from disk repeatedly would be too costly — but it also introduces a subtle gotcha: ts-morph’s in-memory copy doesn’t automatically stay in sync with changes made in the editor. This behavior isn’t clearly documented, and it caused some confusing runtime issues in my extension. After editing a file, ts-morph would continue operating on its stale copy, leading to results that didn’t reflect the latest changes. Fortunately, the solution is straightforward: you just need to manually sync the source files whenever the user opens or edits a file. Here’s how I handled it in my extension: connection.onDidChangeTextDocument(params => { const sourceFile = pr

May 12, 2025 - 14:15
 0
What I Learned Building My First VSCode Extension

I just published my first extension for Visual Studio Code — a language server that adds IntelliSense support for the Obsidian dependency injection framework. In this post, I’ll share some tips and insights I picked up along the way.

1. From Yeoman to AI: Faster Project Scaffolding

When I used to start new projects, I’d always search for a Yeoman template first. For those unfamiliar, Yeoman is a scaffolding tool that helps you create new projects using community-made generators. The idea was great, but in practice, many generators were unmaintained and often produced broken or outdated code.

LLMs have made this problem obsolete — now you can spin up a working project in minutes with a simple prompt. No need to get fancy; even something like this gets the job done:

I want to write a VSCode extension that provides completions and go to definition. Use TypeScript, Jest, and ts-morph. Bundle with esbuild.

2. Traverse the AST like a pro with ts-morph

TypeScript’s compiler API lets you query, traverse, and manipulate your code’s Abstract Syntax Tree (AST) — but it’s low-level and often cumbersome to use. Fortunately, the community-built ts-morph library offers a well-maintained alternative. It builds on the compiler API with a friendlier, more intuitive interface and includes helpful utilities that make working with the AST much smoother and significantly improve developer experience.

To illustrate the difference, here’s how you would add an import statement using TypeScript’s compiler API — a verbose, two-step process:

import * as ts from "typescript";

// Step 1: Create the new import declaration
const importDecl = ts.factory.createImportDeclaration(
  undefined, // decorators
  undefined, // modifiers
  ts.factory.createImportClause(
    false, // isTypeOnly
    undefined, // default import
    ts.factory.createNamedImports([
      ts.factory.createImportSpecifier(
        false, // isTypeOnly
        undefined, // property name
        ts.factory.createIdentifier("MyClass") // local name
      )
    ])
  ),
  ts.factory.createStringLiteral("./my-class")
);

// Step 2: Insert the import into the source file
const updatedSourceFile = ts.factory.updateSourceFile(
  sourceFile,
  [importDecl, ...sourceFile.statements]
);

Now here’s the same task with ts-morph:

sourceFile.addImportDeclaration({
  namedImports: ["MyClass"],
  moduleSpecifier: "./my-class",
});

No factories, no manual AST construction — ts-morph handles the boilerplate for you and lets you focus on what you actually want to do.

3. Handling Extended and Composite tsconfigs with ts-morph

After covering the codebase with a solid suite of integration tests, I felt confident the extension worked and was ready to try it on a real project. I packaged and installed it — but to my surprise, it immediately crashed.

Digging into the stack trace, I discovered that ts-morph wasn’t able to read any TypeScript files in the project. This was unexpected: I had explicitly passed a reference to the project’s tsconfig.json when initializing ts-morph, assuming it would correctly index all source files.

As it turns out, ts-morph doesn’t fully support tsconfig setups with multiple layers of configuration. In my case, the project used a composite tsconfig that referenced other config files. These referenced configs contained the actual include and files properties that define which files are part of the project. Similarly, ts-morph doesn’t handle config files that use the extends property to inherit settings from a base config. In both cases, ts-morph doesn’t merge the configurations, which means it ends up missing large parts of the source code.

This limitation has been reported and acknowledged by the author, but as of now, there’s no built-in solution.

To work around this, I wrote a utility class that manually parses and merges all the relevant tsconfig files before passing the final config to ts-morph. You can find the result here. Later, I also discovered an open source tool called tsconfck that’s dedicated to solving exactly this problem.

4. Keeping ts-morph in Sync with Editor Changes

One thing to be aware of when using ts-morph is that it reads TypeScript files once and then keeps them in memory. This design improves performance — reading from disk repeatedly would be too costly — but it also introduces a subtle gotcha: ts-morph’s in-memory copy doesn’t automatically stay in sync with changes made in the editor.

This behavior isn’t clearly documented, and it caused some confusing runtime issues in my extension. After editing a file, ts-morph would continue operating on its stale copy, leading to results that didn’t reflect the latest changes.

Fortunately, the solution is straightforward: you just need to manually sync the source files whenever the user opens or edits a file. Here’s how I handled it in my extension:

connection.onDidChangeTextDocument(params => {
 const sourceFile = projectAdapter.getSourceFileOrThrow(params.textDocument.uri);
 // Sync the source file with the contents from the editor after a change
 sourceFile.update(params.contentChanges[0].text);
});


connection.onDidOpenTextDocument(params => {
 const sourceFile = projectAdapter.getSourceFileOrThrow(params.textDocument.uri);
 // Sync the source file with the contents from the editor after a file is opened
 sourceFile.update(params.textDocument.text);
});

With this in place, ts-morph stays up to date with what’s actually in the editor, avoiding those confusing desync issues.

5. Extend ts-morph nodes with the Decorator Pattern

Each kind of node in the TypeScript AST is represented by a specific class in the compiler API — for example, arrow functions are instances of ts.ArrowFunction. Ts-morph builds on this by wrapping these low-level nodes in higher-level classes that provide a friendlier API and useful utilities.

This design pattern is known as the Decorator Pattern, and you can apply it by wrapping ts-morph nodes with classes tailored to your domain. I highly recommend this approach — it lets you push logic closer to the nodes they belong to, keeping your codebase organized and reducing the need for scattered helpers and utilities.

Here’s an example from the extension I built, where I wrap ts-morph’s Identifier node with a custom class:

import { Node, Identifier as TSMorphIdentifier } from "ts-morph";
import { hasParentWithDecorator } from "../utils/ts/decorators";
import { assertIdentifier } from "../utils/ts/assertions";

export class Identifier {
  private node: TSMorphIdentifier;

  constructor (node: Node) {
    assertIdentifier(node); // Ensure we're wrapping an Identifier
    this.node = node;
  }

 // Returns true if the identifier's name matches the hook naming pattern (e.g., useSomething).
  public isHook(): boolean {
    return /^use[A-Z][a-zA-Z]*$/.test(this.node.getText());
  }

  // Returns true if this identifier is referenced by a symbol decorated with @provides.
  public isInjected(): boolean {
    return this.node.findReferencesAsNodes().some(
      reference => hasParentWithDecorator(reference, 'provides')
    );
  }
}

By wrapping nodes this way, you turn low-level AST nodes into rich, domain-specific objects that fit naturally into your codebase. It keeps your logic organized, improves readability, and makes future refactoring much easier as your extension grows.

5. Testing LSP events

The official VSCode docs focus mostly on testing extension UI using the test CLI. But since my extension doesn’t have any UI — it’s just a pure language server — I chose a simpler and more practical approach.

This was my first time building a VSCode extension, and I knew there would be plenty of refactoring along the way. Rather than writing unit tests that often break when internal interfaces change, I opted for integration tests that exercise the system through its public LSP events. Since the extension is entirely driven by these events and tightly coupled to them, focusing on end-to-end tests felt like the most natural and resilient approach.

For example, my extension listens to various LSP events — like onDefinition and onCompletion — and routes each event to a corresponding handler class:

connection.onDefinition((params: TextDocumentPositionParams): Promise<Definition | undefined> => {
  return new DefinitionCommand(projectAdapter, logger)
    .onDefinition(params)
    .then((definition) => {
      logger.info(`✅ Found definition: ${JSON.stringify(definition)}`);
      return definition;
    })
    .catch((error) => {
      logger.error(`❌ Error in go to definition: ${error}`);
      return undefined;
    });
});

connection.onCompletion((params: CompletionParams): Promise<CompletionItem[]> => {
  return new CompletionCommand(projectAdapter, logger)
    .getCompletions(params)
    .then((completions) => {
      logger.info(`✅ Found ${completions.length} completions`);
      return completions;
    })
    .catch((error) => {
      logger.error(`❌ Error in completion: ${error}`);
      return [];
    });
});

Testing these handlers turned out to be straightforward. Each event has a clear input (the LSP params), a defined output, and explicit dependencies that I could pass in through the constructor. So, all I had to do was instantiate the handler, call it with predefined test inputs, and assert that the output matched the expected result.

Here’s what one of those tests looks like:

describe('GoToDefinition', () => {
  let uut: DefinitionCommand;

  beforeEach(() => {
    const projectAdapter = createTestProjectAdapter();
    const logger = new FakeLogger();
    uut = new DefinitionCommand(projectAdapter, logger, new StrategyFactory(projectAdapter, logger));
  });

  it.each(testCases.map(testCase => [testCase.name, testCase]))('should go to definition [%s]', async (_name: string, testCase: DefinitionTestCase) => {
    const result = await uut.onDefinition(createParams(testCase));
    expect(result).toEqual(testCase.result);
  });
});

This structure kept my test cases easy to extend and maintain. By focusing on integration tests at the LSP event level, I was able to verify end-to-end behavior — without constantly updating unit tests when internal details shifted.

6. Logging: Build for Debugging First, Enhance Later

From the start, I wrapped all console.log calls in a custom Logger class. By default, logging is disabled in production but when I need to debug an issue in the wild, logging can be easily turned on through a user setting, giving me immediate insights without requiring code changes or redeployment.

This setup has already proven useful during development and early testing. And because the logging is centralized through a single class, it leaves the door open for future enhancements — like integrating a third-party service to capture breadcrumbs and report exceptions automatically.

Wrapping Up

Building my first VSCode extension turned out to be a great learning experience. If you’re thinking about building your own extension, I hope these lessons save you some time (and a few headaches). And if you want to get started quickly, here’s a prompt you can drop into your favorite AI coding assistant to scaffold your own extension with many of the patterns discussed in this post:

"Generate a VSCode extension project in TypeScript that provides completions and go to definition using Language Server Protocol (LSP). Use ts-morph for AST traversal, structure event handlers as classes with clear input/output, and include integration tests using Jest. Bundle with esbuild. Wrap logging in a Logger class with a setting to enable/disable logs at runtime."

Happy coding!