Join our FREE personalized newsletter for news, trends, and insights that matter to everyone in America

Newsletter
New

Develop A Typescript Language Service Plugin: Make Rtk Query's "go To Definition" Smarter

Card image cap

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  

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  

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;  

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);  
          }  
        }  
      }  
    }  
  }  
}  

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;  
}  

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);  
  }  
}  

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;  
}  

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),  
    },  
  };  
}  

Usage

1. Install the Plugin

npm install --save-dev rtk-to-endpoints  

2. Configure tsconfig.json

{  
  "compilerOptions": {  
    "plugins": [  
      {  
        "name": "rtk-to-endpoints"  
      }  
    ]  
  }  
}  

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);  

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)      │    │  
│  └─────────────────────────────────┘    │  
└─────────────────────────────────────────┘  

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.