TypeScript/JavaScript

TypeScript5.9以降、Buffer関連で型エラーが発生する

TypeScript5.9以降で、下記の処理が型エラーになります。

import { readFileSync } from "fs"; const imgApi = () => { const img = readFileSync('/path/to/img.png'); return new Response(img, { headers: { 'Content-Type': 'image/png' } }); // ^ 型 'Buffer<ArrayBufferLike>' の引数を型 'BodyInit | null | undefined' のパラメーターに割り当てることはできません。 }

本記事は、上記の型エラーの背景を探り、解決策を考えます。背景について自分の理解を深めるための調査している分長くなっているので、 サクッと解決したい場合は解決策を見ていただければ。

型エラーの詳細

TypeScript 5.9のリリースノートにおいて、上記について記載があります。

ArrayBuffer has been changed in such a way that it is no longer a supertype of several different TypedArray types. This also includes subtypes of UInt8Array, such as Buffer from Node.js.

ArrayBuffer は変更され、もはや複数の異なる TypedArray 型のスーパータイプではなくなりました。これには、Node.js の Buffer など、UInt8Array のサブタイプも含まれます

readFileSync の型は Buffer です。しかしながらTypeScript5.9、正確には5.7よりBufferなどに型引数が付与されており、型引数が設定されていない場合は Buffer<ArrayBufferLike> が返ってきます。 new Response の期待している引数は ArrayBufferView<ArrayBuffer> です。 ArrayBufferViewBuffer のサブセットですが、型引数が ArrayBufferLikeArrayBuffer で異なるため、型エラーとなっています。

背景

上記の型エラーの詳細を完全に理解するために、いくつか調査をしてみました。

そもそもTypedArrayとはなに

5.7のアップデートでは下記のようなタイトルで型引数が設定されていることが記載されています。

TypedArrays Are Now Generic Over

そもそも TypedArrays ってなんでしょうかね。

MDNのドキュメントを見てみると、TypedArrayは「バイナリデータバッファの配列のようなビュー」と説明されています。まぁ、簡単に言うとメモリ上の生のバイナリデータを、JavaScriptで扱いやすくするための型付き配列という感じですね。

JavaScriptの通常の配列(Array)は、どんな型でも格納できて便利ですが、その分メモリ効率は良くありません。一方でTypedArrayは、特定の型(8ビット符号なし整数とか、32ビット浮動小数点数とか)に特化した配列で、メモリ効率が良いという特徴があります。

本項に関してはこの辺の記事も参考になるはずです:https://zenn.dev/porokyu32/articles/79b81a46cbba2e

TypedArrayの種類

ちなみに、「TypedArray」というのは抽象的な概念で、実際には以下のような具体的な型が存在します:

// 符号なし整数 const uint8 = new Uint8Array([1, 2, 3]); // 8ビット符号なし整数(0~255) const uint16 = new Uint16Array([256, 512]); // 16ビット符号なし整数 const uint32 = new Uint32Array([65536]); // 32ビット符号なし整数 // 符号あり整数 const int8 = new Int8Array([-128, 127]); // 8ビット符号あり整数(-128~127) const int16 = new Int16Array([-32768, 32767]); // 16ビット符号あり整数 // 浮動小数点数 const float32 = new Float32Array([3.14, 2.71]); // 32ビット浮動小数点数 const float64 = new Float64Array([Math.PI]); // 64ビット浮動小数点数(いわゆるdouble)

まぁただ、僕が一般的に見るのは Uint8Array ですかね。というのもNode.jsのBufferも実はUint8Arrayのサブクラスだからですね。今回の型引数の追加がBufferに影響する理由もそこです。

ArrayBufferとの関係

そんでもって、このTypedArrayは必ずArrayBufferの上に構築されます。イメージとしては、ArrayBufferが生のメモリ領域で、TypedArrayがそれを特定の型として解釈するビューという感じでしょうか。

// ArrayBufferを作成(16バイトのメモリ領域) const buffer = new ArrayBuffer(16); // 同じバッファを異なる型として解釈 const view1 = new Uint8Array(buffer); // 16個の8ビット要素として見る const view2 = new Uint32Array(buffer); // 4個の32ビット要素として見る console.log(view1.length); // 16 console.log(view2.length); // 4

このような仕組みになっているため、ArrayBufferの型が変わると、それを参照するTypedArrayの型も影響を受けるんですね。これが今回の型引数追加の背景にあります。

なぜTypedArrayに型引数が付与されたのか

TypeScript 5.7でTypedArrayに型引数が付与された背景には、ECMAScript 2024(ES2024)でArrayBufferとSharedArrayBufferの仕様が大きく変わったことがあります。

ES2024での変更点

型引数が付与されたPRがその辺は詳しいです。

Starting with ES2024, both ArrayBuffer and SharedArrayBuffer diverge significantly due to ArrayBuffer now being resizable, and SharedArrayBuffer being growable:

ES2024以降、ArrayBufferはリサイズが可能に、SharedArrayBufferは拡張が可能になったため、ArrayBufferとSharedArrayBufferは大きく異なるものになった。

具体的には、ES2024でArrayBufferにリサイズ機能(resize()メソッドやtransfer()メソッド)が追加され、SharedArrayBufferには拡張機能(grow()メソッド)が追加されました。その結果、両者は互いに代入できない別々の型になりました。

TypeScript 5.7の解決方法

この問題を解決するために、TypeScript 5.7ではTypedArrayに型引数を付与しました。これによって、TypedArrayを作成する際にどのArrayBufferを使ったかを型レベルで追跡できるようになりました。一方で、デフォルトではArrayBufferLike型が利用されるため、ArrayBufferSharedArrayBuffer を想定している処理で動作しなくなりました。

ちなみに余談も余談ですが、DefinitelyTypedでは一括で対応されたらしいです。

ArrayBufferとSharedArrayBufferってなに

そもそも論ArrayBufferとSharedArrayBufferって何なんでしょうかね。本記事とは直接関係ないのですが調べてみました。

ArrayBuffer:排他的アクセス

MDNのドキュメントによると、ArrayBufferは「汎用的な生のバイナリデータバッファ」です。特徴的なのは、一度に一つの実行コンテキストからしかアクセスできないことです。

// ArrayBufferの作成 const buffer = new ArrayBuffer(16); // 他のコンテキスト(Workerなど)に送信する際は「transfer」される // つまり、元のコンテキストでは使えなくなる worker.postMessage(buffer, [buffer]); console.log(buffer.byteLength); // 0 - detached状態になる

この「transfer」の仕組みによって、メモリの所有権が移転されるため、データ競合やメモリリークを防げるんですね。その分、マルチスレッドでデータを共有するのは簡単ではありません。

ES2024ではArrayBufferに新しい機能が追加されました:

  • リサイズ機能: resize(newByteLength)でサイズを変更可能
  • 転送機能: transfer(newByteLength)で効率的な所有権移転
// リサイズ可能なArrayBuffer(ES2024) const resizableBuffer = new ArrayBuffer(16, { maxByteLength: 64 }); console.log(resizableBuffer.resizable); // true resizableBuffer.resize(32); // サイズを倍に

SharedArrayBuffer:共有アクセス

一方でSharedArrayBufferは、名前の通り複数の実行コンテキストから同時にアクセスできるバッファです。これによって、真の並列処理が可能になります[1]

// SharedArrayBufferの作成 const sharedBuffer = new SharedArrayBuffer(16); // 複数のWorkerから同時にアクセス可能 worker1.postMessage(sharedBuffer); worker2.postMessage(sharedBuffer); // この後、メインスレッドでも使える(transferされない) console.log(sharedBuffer.byteLength); // 16

ES2024ではSharedArrayBufferにも新機能が追加されましたが、拡張のみ可能で縮小はできません:

// 成長可能なSharedArrayBuffer(ES2024) const growableShared = new SharedArrayBuffer(16, { maxByteLength: 64 }); console.log(growableShared.growable); // true growableShared.grow(32); // 成長は可能 // growableShared.shrink(16); // 縮小メソッドは存在しない

こちらもまた余談の余談ですが、SharedArrayBufferを使うには、Webブラウザで特定のセキュリティヘッダーが必要です:

Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp

これはSpectre攻撃などのサイドチャネル攻撃を防ぐための対策で、高精度タイマーと共有メモリの組み合わせが悪用されるのを防いでいるんですね[2]

解決策

下記に関しては、問題は readFileSync の型が Buffer<ArrayBufferLike> だったのが問題でした。

import { readFileSync } from "fs"; const imgApi = () => { const img = readFileSync('/path/to/img.png'); return new Response(img, { headers: { 'Content-Type': 'image/png' } }); // ^ 型 'Buffer<ArrayBufferLike>' の引数を型 'BodyInit | null | undefined' のパラメーターに割り当てることはできません。 }

@types/node パッケージでは22.16(正確には22.15のどこか)などを始め、最新バージョンでは readFileSync などの型が新しくなっています。 NonSharedBuffer 型と AllowSharedBuffer が追加され、それぞれ下記のような定義されています。

454 455
type NonSharedBuffer = Buffer<ArrayBuffer>; type AllowSharedBuffer = Buffer<ArrayBufferLike>;

ref: https://github.com/DefinitelyTyped/DefinitelyTyped/pull/72687

これらを利用して fs などの型が改善されています。そのためnodeの型を最新版に更新すれば治る場合もあると思うので、 @types/node を更新してみましょう。

ただ、ライブラリなどによってはまだ型が対応されていないケースもあるでしょうから、とりあえずは型アサーション等で対応すればよいと思います。

import { readFileSync } from "fs"; const imgApi = () => { const img = readFileSync('/path/to/img.png'); // 型アサーションで解決 return new Response(img as Uint8Array<ArrayBuffer>, { headers: { 'Content-Type': 'image/png' } }); }

最悪 BufferUInt8Array に詰め直す方法も良いでしょう。ただコピーのコストがかかるので、型が更新されるのを待つ(か、自分で修正して貢献する)かするまではTypeScript5.8を使う判断でもいいと思います。

import { readFileSync } from "fs"; const imgApi = () => { const img = readFileSync('/path/to/img.png'); const newArrayBuffer = new ArrayBuffer(img.buffer.byteLength); const newUint8Array = new Uint8Array(newArrayBuffer); newUint8Array.set(img); // sliceはなぜか知らないがBuffer<ArrayBuffer>が返ってくるのでそれを使っても似たような感じになる // sliceは非推奨メソッドっぽいけど // const newUint8Array = img.slice() return new Response(newUint8Array, { headers: { 'Content-Type': 'image/png' } }); }

参考記事

上記で直接参照されていない参照記事は下記です。


  1. 同時アクセスできるってことは、データ競合や競合状態のリスクがあるということでもあります。そのため、SharedArrayBufferを安全に使うためにはAtomics APIと組み合わせることが推奨されています。 ↩︎

  2. このセキュリティ要件については、MDNのSharedArrayBufferのセキュリティ要件で詳しく説明されています。 ↩︎