giftee Tech Blog

ギフティの開発を支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信しています。

TSKaigi 2026 セッションレポート 「tsserverとは何だったのか、これからどうなるのか」に触発されて tsserver とお話してみた

eyecatch

こんにちは、新卒2年目エンジニアの zacker です。 法人向け eギフトサービス giftee for Business のプロダクト開発をしています。

先日開催された TSKaigi 2026 に参加してきました。 私は業務の中では TypeScript は利用していないのですが、個人的に静的型付け言語ファンであることや、JavaScript の書き味をそのまま使える TypeScript が好きで参戦いたしました! TSKaigi の様子は、同じく参加した toki さんの参加レポートもあわせてご覧ください。

今回は TypeScript 7 関連のセッションが多かったのですが、その中でも @nowaki28 さんの「tsserverとは何だったのか、これからどうなるのか」というセッションが面白かったので、その内容をご紹介しつつ、実際に tsserver にお話する様子もお伝えできればと思います!

tsserver とは

tsserver とはエディタの「入力補完」や「ホバーすると型情報が表示される」といった Visual Studio Code ユーザの方にはおなじみの機能を提供してくれるものです。

tsc と異なるのは、編集が終わったファイルを一括で処理するのではなく、リアルタイムに編集されるファイルの内容を見て「補完を良い感じに出す」など、インタラクティブな操作に対応できる必要がある点です。

また、Visual Studio Code に限らず、Vim などのエディタでも tsserver を利用することで同様の機能を使うことができます。

あれ、それって LSP じゃ?

この説明を聞いたときに、「あれ、それ LSP(Language Server Protocol)の話でも聞いたような?」と思ったのですが、どうやら LSP はもともと tsserver の仕組みの標準化を目指したプロジェクトだったようです。

なんでこのタイミングで tsserver の話?

TSKaigi 2026 では、多くのセッションで TypeScript 7microsoft/typescript-go について語られていましたが、今回のセッションのお話もこれらに関係しているようです。

結論から言うと、TypeScript 7 になると tsserver は無くなってしまうようです。 tsgo という Go 言語で実装された tsc が登場するようなのですが、こちらが LSP を実装した言語サーバを提供してくれるためです。

tsserver としゃべってみよう

tsserver を取り巻く状況についてはなんとなく分かったので、実際に tsserver とお話してみようと思います!

簡単な TypeScript のコードをを用意して、Visual Studio Code 等のエディタで編集するときに発生する tsserver とのやり取りを、直接 tsserver にコマンドを送ることで模倣してみます。

全体像を掴んでみよう

tsserver は TypeScript がインストールされている環境で npx tsserver とすると起動することができます。 tsserver とお話するときには、いくつかお作法があるようです。

まず、いきなり「型情報くれや」とお願いすることはできず、「このファイルを開いてください」と伝えるところからのようです。 こうすることで、tsserver のインメモリ上にファイルの内容が展開され、そのデータに対して操作をしていきます。

操作には「型などの情報の参照」「補完」など様々あり「コマンド(command)」として指定されます。 こうしたやり取りは JSON を標準入出力に流し込むことで行われるのですが、先取りしてお見せすると、以下のような内容が送信されます。

{"command": "open", "arguments": { "file": "/tmp/tsserver-playground/sample.ts" }}

調べる前は「どうやってやり取りしているんだろう」ということすら分らなかったのですが、意外と古典的な方法でやっているんですね。

また、リクエストは改行を入力することで送信したとみなされます。 あまりないと思いますが、キーボードで直打ちするときには、JSON を入力したあとに Enter キーを押すことで送信されます。

「ここからここまでがペイロードです」ということを伝える手段はリクエストとレスポンスで異なります。 リクエスト時には \n が区切り文字として利用されますが、レスポンスの場合 Content-Length でペイロードの文字数を指定することで、ペイロードの範囲をクライアントに伝えます(microsoft/TypeScript-wiki/Standalone-Server-(tsserver).md#Message Format)。

メッセージの送信の仕方がリクエストとレスポンスで非対称なのは少し謎ですね。 なぜこのような設計になっているかということを考えてみたのですが、以下のような整理になるのではと予想しています。

  • 前提
    • 改行を区切り文字として使えるとパース処理が楽
  • リクエスト
    • パラメータに含まれる値には単純な値( \n を含まない値)しか利用されない
    • → 改行を区切り文字として利用できる
  • レスポンス
    • エラーなどの中に \n が含まれる可能性がある
    • Content-Length でメッセージの範囲を指定する

ファイルを開いてみよう

まず、tsserver を起動してみます。

npx tsserver

ファイルを開くためには open コマンドを使います。

今回は、/tmp/tsserver-playground/sample.ts にあるファイルを開いてみます。 ※ 実際には 1 行にまとめられた JSON でやり取りするのですが、読みにくいので展開した状態の JSON をお見せしています。

{
  "seq": 1,
  "type": "request",
  "command": "open",
  "arguments": {
    "file": "/tmp/tsserver-playground/sample.ts"
  }
}

このように入力すると、以下のようにレスポンスが返ってきます。

※ レスポンスも実際には 1 行の JSON が返ってきますが、見やすくするために展開しています。

Content-Length: 200

{
  "seq": 0,
  "type": "response",
  "command": "open",
  "request_seq": 1,
  "success": true,
  "performanceData": {
    "updateGraphDurationMs": 222.609166000002,
    "createAutoImportProviderProgramDurationMs": 34.503875000000335
  }
}

ファイルが開かれていそうなことと、Content-Length が JSON の文字数 + 1 であることが確認できました。 この + 1 文字分は JSON 末尾の \n なのかもしれません。

$ echo -n '{"seq":0,"type":"response","command":"open","request_seq":1,"success":true,"performanceData":{"updateGraphDurationMs":222.609166000002,"createAutoImportProviderProgramDurationMs":34.503875000000335}}' | wc -c
199 

実際の通信は、以下のような様子になります。

tsserver

型を教えてもらおう

TypeScript といったら「型」なので、tsserver に変数の型を教えてもらいましょう。

以下のようなコードを用意してみます。

const x = 10;

「型」情報をもらうために quickinfo というコマンドを利用します。 カーソルがあたったときに出てくるあれです。

quickinfo

「x のところにカーソルを当てた状態」を以下のように表現してみます。

{
  "seq": 2,
  "type": "request",
  "command": "quickinfo",
  "arguments": {
    "file": "/tmp/tsserver-playground/sample.ts",
    "line": 1,
    "offset": 7
  }
}

すると、tsserver から "displayString":"const x: 10" という結果が返ってきました。 エディタで変数にカーソルをあてたときに見る結果と同じですね。

Content-Length: 241

{
  "seq": 0,
  "type": "response",
  "command": "quickinfo",
  "request_seq": 2,
  "success": true,
  "body": {
    ... 
    "displayString": "const x: 10",
    ...
  }
}

ところで、先程は const で試してみたので 10 というリテラル型が返ってきましたが let とするとどうなるでしょうか?

let x = 10
{"seq":2,"type":"request","command":"quickinfo","arguments":{"file":"/tmp/tsserver-playground/sample.ts","line":1,"offset":5}}

今度は x の型は number となりました!

Content-Length: 241

{
  "seq": 0,
  "type": "response",
  "command": "quickinfo",
  "request_seq": 2,
  "success": true,
  "body": {
    ...
    "displayString": "let x: number",
    ...
  }
}

ちなみに、open コマンドを入力する前に、いきなり quickinfo コマンドを実行すると、tsserver 上でファイルを開けていないためエラーになります。

{"seq":2,"type":"request","command":"quickinfo","arguments":{"file":"/tmp/tsserver-playground/sample.ts","line":1,"offset":7}} 

Content-Length: 2174
{
    "seq":0,
    "type":"response",
    "command":"quickinfo",
    "request_seq":2,
    "success":false,
    "message":"Error processing request. No Project.\nError: No Project.\n
    ...

おしゃべりの内容を覗いてみよう

ちなみに先ほどリクエストの詳細な部分はスキップしてしまったのですが、少しだけ踏み込んでみます。 リクエストで送信した JSON を展開してみると以下のようになります。

{
  "seq": 2,
  "type": "request",
  "command": "quickinfo",
  "arguments": {
    "file": "/tmp/tsserver-playground/sample.ts",
    "line": 1,
    "offset": 7
  }
}

command でやりたい操作を宣言します。 利用できる commandprotocol.ts で定義されているようです。

command の引数となるものを arguments に渡していきます。 対象となるファイルの場所、line, offset でカーソルがあたっている位置の「行数」と「行頭から何文字目か」という情報を渡しています。 先ほどの例で型情報を調べたときには、「1行目の先頭から7文字目」という情報を渡していました。

tsserver_offset

後ほど紹介する「補完」操作をするときにも、同様のリクエストを送信することになるので、この形さえ理解できてしまえば tsserver で何をしているかは分らなくても、どんな会話がされているかは、なんとなく読めてきます。

ということで、今日からあなたも tsserver に話しかけることができるわけですね!(強引)

複数のリクエストを同時に送ってみよう

先ほどお見せしたリクエストとレスポンスの中に、"seq": 0"request_seq": 0 という項目が気になりました。 こちらは、リクエストとレスポンスの対応関係を取るために使われるようです。

{"seq":3,"type":"request","command":"quickinfo","arguments":{"file":"/tmp/tsserver-playground/sample.ts","line":1,"offset":7}}
{"seq":4,"type":"request","command":"quickinfo","arguments":{"file":"/tmp/tsserver-playground/sample.ts","line":1,"offset":7}}
Content-Length: 241

{"seq":0,"type":"response","command":"quickinfo","request_seq":3,"success":true,"body":{"kind":"const","kindModifiers":"","start":{"line":1,"offset":7},"end":{"line":1,"offset":8},"displayString":"const x: 10","documentation":"","tags":[]}}
Content-Length: 241

{"seq":0,"type":"response","command":"quickinfo","request_seq":4,"success":true,"body":{"kind":"const","kindModifiers":"","start":{"line":1,"offset":7},"end":{"line":1,"offset":8},"displayString":"const x: 10","documentation":"","tags":[]}}

おぉ!! 2つ同時にリクエストを送ったら、2つ同時にレスポンスが返ってきました。

補完をしてみよう

では、応用問題として「補完」操作を試してみましょう!

const x = 1;
const msg = x.to

x.to のあとにカーソルを当てた状態で completions コマンドを送ってみます。

{
  "seq": 5,
  "type": "request",
  "command": "completions",
  "arguments": {
    "file": "/tmp/tsserver-playground/sample.ts",
    "line": 2,
    "offset": 17
  }
}
Content-Length: 572

{
  "seq": 0,
  "type": "response",
  "command": "completions",
  "request_seq": 5,
  "success": true,
  "body": [
    { "name": "toExponential", "kind": "method", "kindModifiers": "declare", "sortText": "11" },
    { "name": "toFixed", "kind": "method", "kindModifiers": "declare", "sortText": "11" },
    ....
  ]
}

少し量が多いので一部省略していますが、to から始まる number 型のメソッドが返ってきました。 エディタで入力しているときに出てくる補完は、こうして実現されているんですね。

LSP としゃべってみよう

tsserver とお話できたので、次は LSP とお話していきたいと思います。

全体像を掴んでみよう

最初にお伝えしたとおり、LSP は tsserver の標準化を目指したプロジェクトのため、多くの操作で互換性があります。 ただ、tsservercommandmethod という名称に変わっていたり、quickinfo コマンドが textDocument/hover に変わっているなど細かい名前の違いがあります。

操作 (command/method) tsserver LSP
定義ジャンプ definition textDocument/definition
参照検索 references textDocument/references
リネーム rename textDocument/rename
補完 completionInfo textDocument/completion
ホバー情報 quickinfo textDocument/hover
フォーマット format textDocument/formatting
ファイルリネーム getEditsForFileRename workspace/willRenameFiles

セッションスライドから引用)

また、JSON を標準入出力でやり取りするという点も同じです。

ただし、tsserver と違い、LSP ではリクエスト側も送信する JSON の Content-Length を書く必要があります。 また、Content-Length と JSON の間には \r\n\r\n を入力する必要があります。

Content-Length: 132\r\n\r\n{"jsonrpc":"2.0","id":1,...

実は、\r をキーボードで入力するのが至難の業です。 単純に \r と入力してしまうと、\r という2文字として扱われてしまうため工夫が必要になります。 ターミナルから入力するときには、Ctrl+VEnter と押下すると ^M という値が入力され、これが \r 扱いになるようです。 LSP とキーボード直打ちでお話するとき以外に使えなさそうな知識が手に入りました。

tsgo

まずは、initialize をしよう

その前に、LSP サーバを起動してみます。

npx @typescript/native-preview --lsp --stdio

tsserver に無かった概念として、initialize という操作があります。 接続を開始してサーバーの機能を確認するための操作で、最初に必ず送る必要があります。

Content-Length: 132\r\n\r\n{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "processId": null,
    "rootUri": "file:///tmp/tsgo-playground",
    "capabilities": {}
  }
}
Content-Length: 1677

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "capabilities": { "positionEncoding": "utf-16", "textDocumentSync": { "openClose": true, "change": 2, "save": true }, "completionProvider": { "triggerCharacters": [ ".", "\"", "'", "`", "/", "@", "<", "#",],  "resolveProvider": true }, "hoverProvider": true, ... },
    "serverInfo": {
      "name": "typescript-go",
      "version": "7.0.0-dev.20260527.2"
    }
  }
}

レスポンスの capabilities に、サーバーが対応している操作の一覧が返ってきます。hoverProvider: true など、先ほどの対応表にあった操作が並んでいますね。また serverInfo から typescript-go バージョン 7.0.0-dev であることも確認できます。

initialize のあとは、initialized という通知を送ってセッション開始を伝えます(レスポンスはありません)。

Content-Length: 49\r\n\r\n{"jsonrpc":"2.0","method":"initialized","params":{}}

ファイルを開いてみよう

LSP でのファイルオープンは textDocument/didOpen というコマンドを使います。tsserveropen と異なり、ファイルパスだけでなくファイルの内容もあわせて送ります。

Content-Length: 204\r\n\r\n{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"file:///tmp/tsgo-playground/sample.ts","languageId":"typescript","version":1,"text":"const x = 10;\n"}}}

型を教えてもらおう

型情報を取得するには textDocument/hover を使います。tsserverquickinfo に相当する操作です。line, offset の代わりに position オブジェクトで位置を指定します。 行頭からの文字数を指定するときに、tsserver は最初の文字を「1文字目」としていましたが、tsgo では「0文字目」と指定するようです。

Content-Length: 163\r\n\r\n{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "textDocument/hover",
  "params": {
    "textDocument": { "uri": "file:///tmp/tsgo-playground/sample.ts" },
    "position": { "line": 0, "character": 6 }
  }
}
Content-Length: 163

{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "contents": {
      "kind": "plaintext",
      "value": "const x: 10"
    },
    "range": {
      "start": { "line": 0, "character": 6 },
      "end": { "line": 0, "character": 7 }
    }
  }
}

tsserver のときと同じく "const x: 10" が返ってきました。 やはり、得られる値も tsserver とかなり似ていますね。

おわりに

この記事ではセッションの内容をお伝えしつつ、実際に tsserverLSP とお話してみました。 

私の中で「LSP は何をしてくれているのか?」という部分がブラックボックスになってしまっていたのですが、実際に触ってみると「あっ、標準入出力で話せるんだ」というところから親近感を覚えました(笑) また、だんだんと会話の中身も理解できる感覚があって面白かったです。 今まで触れてこなかった領域が広がっていくのはテックイベントの醍醐味ですね。

@nowaki28 さんのセッションを通じて、普段触れない領域に新しい興味を持つことができました! また、これだけ熱気あふれる場を作ってくださった企画・運営の皆様、本当にありがとうございました!