DEV Community

flycran
flycran

Posted on

Develop a TypeScript language service plugin: Make RTK Query's "Go to Definition" smarter

Introduction

Have you ever encountered this frustration when developing with Redux Toolkit Query (RTK Query)?

When you want to check the implementation of an API endpoint, pressing F12 (Go to Definition) takes you to the type definition file instead of the actual business logic. You have to manually search for the endpoint name to find its definition in createApi.

This is a common problem because RTK Query hook names (like useGetUserQuery) are dynamically generated, and TypeScript cannot establish a static mapping from hook calls to endpoint definitions.

Today, I'll show you how to develop a TypeScript Language Service Plugin to solve this problem, allowing developers to jump directly to RTK Query endpoint definitions with a single click.


Problem Background

How RTK Query Works

RTK Query creates API slices through createApi:

export const userApi = createApi({
  reducerPath: 'userApi',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  endpoints: (builder) => ({
    getUser: builder.query<User, string>({
      query: (id) => `/users/${id}`,
    }),
    updateUser: builder.mutation<User, Partial<User>>({
      query: (body) => ({
        url: '/users',
        method: 'POST',
        body,
      }),
    }),
  }),
})

// Auto-generated hooks
export const { useGetUserQuery, useUpdateUserMutation } = userApi
Enter fullscreen mode Exit fullscreen mode

Pain Points

  1. Hook names are dynamically derived: getUseruseGetUserQuery
  2. TypeScript only sees types: IDE's "Go to Definition" can only point to type gymnastics generated type definitions
  3. Broken developer experience: Developers need to manually search for endpoint names, interrupting the coding flow

Solution: TypeScript Language Service Plugin

What is a Language Service Plugin?

TypeScript Language Service Plugin is an extension mechanism that allows us to intercept and customize various TypeScript language service operations, including:

  • Go to Definition
  • Auto Completion
  • Hover Information
  • Code Refactoring

Core Idea

Our plugin needs to accomplish the following:

  1. Identify RTK Query Hooks: Recognize hooks like use{Endpoint}Query, use{Endpoint}Mutation through naming conventions
  2. Parse AST: Find the API instance the hook belongs to
  3. Locate Endpoint: Find the corresponding endpoint definition from the API instance's endpoints property
  4. Return Definition Location: Point the jump target to the endpoint definition

Implementation Details

1. Project Structure

rtk-to-endpoints/
├── src/
│   ├── index.ts      # Plugin entry
│   └── utils.ts      # Core logic
├── package.json
└── tsconfig.json
Enter fullscreen mode Exit fullscreen mode

2. Plugin Entry (index.ts)

import tslib from "typescript/lib/tsserverlibrary";
import { getDefinitionAndBoundSpan } from "./utils.js";

function init(modules: { typescript: typeof tslib }) {
  const ts = modules.typescript;

  function create(info: tslib.server.PluginCreateInfo) {
    const logger = info.project.projectService.logger;

    log("✅ Plugin initialized");

    const proxy: tslib.LanguageService = Object.create(info.languageService);

    // Intercept "Go to Definition" request
    proxy.getDefinitionAndBoundSpan = (
      fileName: string,
      position: number
    ): tslib.DefinitionInfoAndBoundSpan | undefined => {
      const program = info.languageService.getProgram();

      // Try our custom jump logic
      const definitionInfo = getDefinitionAndBoundSpan(
        fileName, position, ts, program
      );

      // If RTK Query hook is matched, return custom result
      // Otherwise, fall back to default behavior
      return definitionInfo || 
        info.languageService.getDefinitionAndBoundSpan(fileName, position);
    };

    return proxy;
  }

  return { create };
}

export = init;
Enter fullscreen mode Exit fullscreen mode

3. Core Logic (utils.ts)

3.1 Recognizing Hook Naming Patterns

RTK Query generated hooks follow fixed naming conventions:

const HOOK_PREFIXES = ["useLazy", "use"] as const;
const HOOK_SUFFIXES = [
  "InfiniteQueryState",
  "InfiniteQuery", 
  "QueryState",
  "Mutation",
  "Query",
] as const;

// Extract endpoint name from hook name
export function extractEndpointName(hookName: string) {
  for (const prefix of HOOK_PREFIXES) {
    if (hookName.startsWith(prefix)) {
      const rest = hookName.slice(prefix.length);
      for (const suffix of HOOK_SUFFIXES) {
        if (rest.endsWith(suffix)) {
          const endpointName = rest.slice(0, rest.length - suffix.length);
          if (endpointName) {
            // Lowercase first letter: GetUser → getUser
            return endpointName[0].toLowerCase() + endpointName.slice(1);
          }
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

3.2 AST Node Lookup

Use binary search to quickly locate the node at cursor position in the AST:

export function getIdentifierNodeAt(
  sourceFile: tslib.SourceFile,
  pos: number,
): tslib.Node | undefined {
  let current: tslib.Node = sourceFile;

  while (true) {
    const children = current.getChildren(sourceFile);
    let left = 0;
    let right = children.length - 1;
    let targetChild: tslib.Node | undefined;

    // Binary search for child node covering the specified position
    while (left <= right) {
      const mid = (left + right) >>> 1;
      const child = children[mid];
      if (pos < child.pos) {
        right = mid - 1;
      } else if (pos >= child.end) {
        left = mid + 1;
      } else {
        targetChild = child;
        break;
      }
    }

    if (!targetChild) break;
    current = targetChild;
  }

  return current;
}
Enter fullscreen mode Exit fullscreen mode

3.3 Finding API Instance

Support two common API usage patterns:

export function findApi(node: tslib.Node, ts: typeof tslib) {
  const parent = node.parent;

  // Pattern 1: Destructuring assignment
  // const { useGetUsersQuery } = userApi
  if (ts.isBindingElement(parent)) {
    const expressionNode = parent.parent?.parent;
    if (!ts.isVariableDeclaration(expressionNode)) return;
    const apiNode = expressionNode.getChildAt(
      expressionNode.getChildCount() - 1
    );
    if (!apiNode || !ts.isIdentifier(apiNode)) return;
    return apiNode;

  // Pattern 2: Property access
  // userApi.useGetProductsQuery()
  } else if (parent && ts.isPropertyAccessExpression(parent)) {
    return parent.getChildAt(parent.getChildCount() - 3);
  }
}
Enter fullscreen mode Exit fullscreen mode

3.4 Locating Endpoint Definition

Use TypeScript's type checker to find the target endpoint from the API instance's endpoints property:

export function findEndpoint(
  apiNode: tslib.Node, 
  endpointName: string, 
  checker: tslib.TypeChecker
) {
  // Get the type of API instance
  const apiType = checker.getTypeAtLocation(apiNode);

  // Get endpoints property
  const endpointsSymbol = apiType.getProperty('endpoints');
  if (!endpointsSymbol) return;

  // Get the type of endpoints
  const endpointsType = checker.getTypeOfSymbol(endpointsSymbol);

  // Find specific endpoint
  const endpointsPropertySymbol = endpointsType.getProperty(endpointName);
  return endpointsPropertySymbol;
}
Enter fullscreen mode Exit fullscreen mode

3.5 Assembling Definition Information

export function getDefinitionAndBoundSpan(
  fileName: string, 
  position: number, 
  ts: typeof tslib, 
  program?: tslib.Program
) {
  const sf = program!.getSourceFile(fileName);
  const checker = program!.getTypeChecker();
  if (!sf || !program || !checker) return;

  // 1. Find identifier node at cursor position
  const identNode = getIdentifierNodeAt(sf, position);
  if (!identNode || !ts.isIdentifier(identNode)) return;

  // 2. Extract endpoint name
  const endpointName = extractEndpointName(identNode.getText());
  if (!endpointName) return;

  // 3. Find API instance
  const apiNode = findApi(identNode, ts);
  if (!apiNode) return;

  // 4. Find endpoint definition
  const endpointSymbol = findEndpoint(apiNode, endpointName, checker);
  if (!endpointSymbol?.declarations?.length) return;

  // 5. Assemble definition information
  const definitions = endpointSymbol.declarations.map((node): tslib.DefinitionInfo => {
    return {
      fileName: node.getSourceFile().fileName,
      kind: ts.ScriptElementKind.memberFunctionElement,
      name: endpointSymbol.getName(),
      containerKind: ts.ScriptElementKind.classElement,
      containerName: "endpoints",
      textSpan: {
        start: node.getStart(),
        length: node.getWidth(),
      },
    };
  });

  return {
    definitions,
    textSpan: {
      start: identNode.getStart(sf),
      length: identNode.getWidth(sf),
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

Usage

1. Install the Plugin

npm install --save-dev rtk-to-endpoints
Enter fullscreen mode Exit fullscreen mode

2. Configure tsconfig.json

{
  "compilerOptions": {
    "plugins": [
      {
        "name": "rtk-to-endpoints"
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

3. Configure VSCode

Since VSCode's built-in TypeScript cannot read npm packages from the project, you need to set VSCode to use the workspace TypeScript version:

  1. Ctrl+Shift+P → Type "TypeScript: Select TypeScript Version"
  2. Select "Use Workspace Version"
  3. Reload window (Developer: Reload Window)

Demo

After configuration, when you use "Go to Definition" on any RTK Query hook:

// Click useGetUserQuery to jump directly to getUser endpoint definition
const { data } = userApi.useGetUserQuery(userId);
Enter fullscreen mode Exit fullscreen mode

Before:

  • Points to type definition file (no practical business value)

After:

  • Directly locates the getUser endpoint definition in createApi

Technical Summary

1. TypeScript Language Service Architecture

┌─────────────────────────────────────────┐
│           VSCode / IDE                  │
└─────────────┬───────────────────────────┘
              │ LSP Protocol
┌─────────────▼───────────────────────────┐
│      TypeScript Language Server         │
└─────────────┬───────────────────────────┘
              │
┌─────────────▼───────────────────────────┐
│    TypeScript Language Service          │
│  ┌─────────────────────────────────┐    │
│  │  rtk-to-endpoints Plugin        │    │
│  │  (Intercept getDefinition)      │    │
│  └─────────────────────────────────┘    │
└─────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

2. Key Technical Points

Technical Point Description
AST Traversal Use binary search to efficiently locate nodes
Type Checker Use TypeChecker to parse type information
Proxy Pattern Wrap original Language Service, preserve default behavior
Naming Resolution Identify hook types through string pattern matching

Extension Ideas

The implementation of this plugin can be extended to other similar scenarios:

  1. Vue Composition API: Jump from useXxx to composable definitions
  2. React Hooks: Enhance custom hook navigation experience

Conclusion

TypeScript Language Service Plugin is a powerful tool that can significantly improve the developer experience. By understanding TypeScript's compiler API and language service architecture, we can build smarter IDE support for specific frameworks and libraries.

I hope this article helps you understand how Language Service Plugins work and inspires you to develop similar tools for your own projects.


References


If this article helped you, please like, bookmark, and share!

Feel free to leave comments if you have any questions or suggestions.

Top comments (0)