TypeScript で TextDecoder と TypedArray の取り扱いではまった

TypeScript と書いているが、JavaScript にも当てはまる内容になっているはず。

tl;dr

  • utf-8 のバイト列(TypedArray) → string への変換で TextDecoder を使った
  • Uint8Array をそのまま TextDecoder.decode() に投げたら生成された文字列がいまいちおかしい
  • Uint8Array.prototype.subarary() を使うとコピーせずに部分配列を作れる

おさらい

いきなり TypedArray とか言われてもピンとこないと思うので関連するオブジェクト周りについておさらいしておく。

ArrayBuffer

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer
固定長の生のバイナリデータを扱うためのオブジェクト。他の言語でいうバイト配列。
しかし直接操作することはできないため、後述する DataView や TypedArray を使う必要がある。

DataView

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/DataView
ArrayBuffer を操作するためのインターフェースオブジェクト(ビュー)。ArrayBuffer は生のバイナリデータであるため操作する際は環境毎のエンディアンを考慮する必要があるが DataView を使うとエンディアンに依存せず読み書きできる。

TypedArray

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/TypedArray
ArrayBuffer を配列形式で扱えるようにするためのインターフェースオブジェクト(ビュー)の総称。
あくまで総称なので TypedArray という名前のオブジェクト自体は存在しない。
TypedArray には Uint8ArrayInt32Array などが存在する。繰り返しになるが、これはあくまで View であり、実体は ArrayBuffer である。

TextDecoder

https://developer.mozilla.org/ja/docs/Web/API/TextDecoder Web API の一つで、UTF-8EUC-JP などのデコーダーを提供する。
TypedArray を受け取り、デコードした結果を文字列として返す。
対となる API として TextEncoder がある。

はまったこと

以下のようなことをしていた。

// 適当に buffer を確保
const buf = new Uint8Array(32);

// 実際はここで Stream からデータを読み込んでいたが、ここでは TextEncoder の書き込みとしておく
// e.g. await s.read(buf);
new TextEncoder().encodeInto("Hello, World!", buf);

// byte列 → string 変換
const decoded = new TextDecoder().decode(buf);

console.log(decoded);                                     // "Hello, World!"
console.log(decoded.replace("Hello, World!", ""));        // ""
console.log(decoded.replace("Hello, World!", "").length); // 19 <-- !?

byte 列の操作に慣れている人からしたら当たり前かもしれないが TextDecoder は特定の byte 列からゼロ値までをいい感じに decode してくれるのではない。
与えられた byte 列をすべて decode しようとする。

decoded を表示した際に "Hello, World!" と表示されたが、実際には見えていないだけで Null 文字が28個続いているのである。

どのように対応したか

const buf = new Uint8Array(32);

// byte 列操作系のメソッドは大体読み込み・書き込みした byte 数を返してくれる
// e.g. const n = await s.read(buf);
const { written: n } = new TextEncoder().encodeInto("Hello, World!", buf);

const decoded = new TextDecoder().decode(buf.subarray(0, n));

console.log(decoded);                                     // "Hello, World!"
console.log(decoded.replace("Hello, World!", ""));        // ""
console.log(decoded.replace("Hello, World!", "").length); // 0 <-- 👍

TypedArray には TypedArray.prototype.subarray(begin[, end]) があり、メモリのコピーなしに同じ型の新しい TypedArray を返してくれる。
メモリのコピーが発生しないということは、物理メモリ的は元の TypedArray が参照している ArrayBuffer の実体と同じであるということなので、ここで生成された TypedArray への変更は元の TypedArray にも影響をあたえる。逆もまた然りである。

const base = new Uint8Array([4, 2]);
const copied = base.subarray(0);

copied[0] = 0;
console.log(base.toString());   // 0,2
console.log(copied.toString()); // 0.2

配列のコピー・複製を行う場合は TypedArray.from(source[, mapFn[, thisArg]]) を使うと良い。

const base = new Uint8Array([4, 2]);
const copied = Uint8Array.from(base);

copied[0] = 0;
console.log(base.toString());   // 4,2
console.log(copied.toString()); // 0.2

まとめ

TextDecoder に TypedArray を渡すときは subarray を用いて適切な範囲指定の TypedArray 作ってそれを渡してあげたほうが事故がなくて良いと思う。

余談

ちなみになぜこの問題にあたったのかと言うと TypeScript で TCP Server を立てて独自プロトコルの parse をしていたためである。
はじめは通信周りのバグか何かだろうと思い tcpdump を使って binary packet を眺めていたのだがてんで見当違いであった。