はじめに
Web Push通知を実装していて、VAPID公開鍵を Uint8Array に変換して pushManager.subscribe() に渡したところ、次の型エラーが出ました。
Type error: Type 'Uint8Array<ArrayBufferLike>' is not assignable to type 'string | BufferSource | null | undefined'.
Type 'Uint8Array<ArrayBufferLike>' is not assignable to type 'ArrayBufferView<ArrayBuffer>'.
Types of property 'buffer' are incompatible.
Type 'ArrayBufferLike' is not assignable to type 'ArrayBuffer'.
関数の中身を何度書き換えても直らず、かなり遠回りしました。最終的な原因は、関数の戻り値の型の書き方でした。
この記事では、Uint8Array や ArrayBuffer を初めて見る人にもわかるように、エラーの意味と解決方法をまとめます。
結論
関数の戻り値の型を Uint8Array と書いている場合は、Uint8Array<ArrayBuffer> に変えると解決します。
// 修正前
function urlBase64ToUint8Array(base64String: string): Uint8Array {
// 修正後
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
筆者の環境はTypeScript 5.9.3です。
問題
なぜ Uint8Array に変換したのか
最初は、VAPID公開鍵を文字列のまま applicationServerKey に渡していました。
ところが、iPhoneで通知を許可しても、購読の処理が何のエラーも出さずに失敗していました。原因がわからずClaude Codeに相談したところ、「iOSは文字列ではなく Uint8Array を要求する」という回答があり、VAPID公開鍵を Uint8Array に変換する処理を追加しました。今回の型エラーは、このときに発生したものです。
あとから確認したところ、MDNでは applicationServerKey に文字列も渡せると説明されていました。また、購読に失敗していた本当の原因は、環境変数に入っていたVAPID公開鍵の末尾の空白でした。
「iOSは文字列を受け付けない」という説明は、確かめられた事実ではありませんでした。筆者は、空白のない正しいキーを文字列のまま渡してiPhoneで動くかは試していません。
型エラーが出たコード
変換する関数を作り、pushManager.subscribe() に渡していました。
// VAPID公開鍵(文字列)を Uint8Array(数字の並び)に変換する
function urlBase64ToUint8Array(base64String: string): Uint8Array {
// base64url形式を、atob() が読める通常のbase64形式に直す
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
// base64の文字列を、元のデータ(1文字 = 1バイト)に戻す
const rawData = atob(base64);
// 1文字ずつ数字(0〜255)に変換して、Uint8Array にする
return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0)));
}
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidKey), // ← ここで型エラー
});
ビルドすると、applicationServerKey の行で冒頭の型エラーが出ました。
遠回りしたこと
最初は、関数の中身に問題があると考えて、次のように書き換えました。
-
Uint8Array.from()をやめて、new Uint8Array(長さ)で作る → エラーのまま -
new ArrayBuffer()でデータ領域を作ってからUint8Arrayを作る → エラーのまま
どちらも直りませんでした。原因は中身ではなく、1行目の : Uint8Array という戻り値の型の書き方にあったからです。
解決方法
エラーの意味を理解するために、まず4つの前提知識を順番に説明します。
前提知識①:applicationServerKey に渡しているもの
applicationServerKey には、VAPIDキーの公開鍵を渡します。VAPIDキーは、公開鍵と秘密鍵のペアです。印鑑にたとえると、次のような関係です。
| VAPIDキー | たとえ | 持っている人 |
|---|---|---|
| 秘密鍵 | 印鑑そのもの | サーバーだけ |
| 公開鍵 | 印鑑を押した跡(印影)の見本 | 誰に見せてもいい |
ブラウザは購読するときに、公開鍵(印影の見本)をPushサービス(AppleやGoogleの通知を配るサーバー)に登録します。サーバーが秘密鍵で「印鑑」を押して通知を送ると、Pushサービスは見本と照らし合わせて、一致したときだけスマホに届けます。これで、秘密鍵を持たない他人のサーバーからは通知を送れなくなります。
前提知識②:公開鍵の正体は「バイナリデータ」
バイナリデータとは、コンピューターが扱う生のデータのことで、0〜255の数字の並びとして表せます。この0〜255の数字1つ分を1バイトと呼びます。
VAPIDの公開鍵の正体は、65バイトのバイナリデータ、つまり65個の数字の並びです。ただ、数字の並びのままでは環境変数に書きにくいので、普段はbase64urlという方法で文字に置き換えて扱います。
使い捨てのVAPIDキーを1つ作って、中身を確かめてみました。
| 書き方 | 実際の見た目 | 長さ |
|---|---|---|
| 文字列(base64url) | BINSEpflXBOV... |
87文字 |
| バイナリデータ(数字の並び) | 4, 131, 82, 18, 151, 229, 92, 19, ... |
65個(65バイト) |
どちらも同じ公開鍵で、書き方が違うだけです。
仕様上は、applicationServerKey にはどちらの書き方で渡してもかまいません。記事の urlBase64ToUint8Array 関数は、文字列で書かれた公開鍵を、元のバイナリデータ(数字の並び)に戻す関数です。
VAPIDの仕様(RFC 8292)では、公開鍵はbase64urlで文字列にして扱うと決められています。
また、使い捨てのキーで確かめたところ、公開鍵の最初の数字は 4 で、文字列にすると「B」から始まっていました。4 は、公開鍵が「圧縮しない形式」であることを表す目印です。
前提知識③:ArrayBuffer は「箱」、Uint8Array は「メガネ」
JavaScriptでバイナリデータを扱うときは、ArrayBuffer と Uint8Array の2つを組み合わせて使います。
| 名前 | たとえ | 役割 |
|---|---|---|
ArrayBuffer |
箱 | バイナリデータを実際にしまっておく場所。65バイトの箱なら、65個の数字が入る |
Uint8Array |
メガネ | 箱の中身を「0〜255の数字の配列」として読み書きできるようにするもの |
ArrayBuffer(箱)は、それだけでは中身を読み書きできません。Uint8Array(メガネ)を通すことで、普通の配列のように [0] や [1] で中身を扱えるようになります。
const box = new ArrayBuffer(65); // 65バイト分の箱を用意する
const view = new Uint8Array(box); // 箱を「0〜255の数字の配列」として見るメガネをかける
view[0] = 4; // メガネを通して、箱の1番目に 4 を入れる
console.log(view[0]); // → 4
Uint8Array.from(...) や new Uint8Array(65) のように書いたときは、箱も自動で用意されます。Uint8Array を作ると、必ずその裏に箱があると覚えておいてください。
前提知識④:Uint8Array<〇〇> は「どの種類の箱を見ているか」
箱には、次の2種類があります。
| 箱の種類 | 意味 |
|---|---|
ArrayBuffer |
普通の箱。ほとんどの場面ではこちら |
SharedArrayBuffer |
複数の処理(スレッド)で共有する、特別な箱 |
TypeScript 5.7から、Uint8Array は < > を使って、メガネがどの種類の箱を見ているかを型で表せるようになりました。Array<string>(文字列の配列)と同じ書き方です。
| 型 | 意味 |
|---|---|
Uint8Array<ArrayBuffer> |
普通の箱を見ているメガネ |
Uint8Array<SharedArrayBuffer> |
特別な箱を見ているメガネ |
Uint8Array<ArrayBufferLike> |
どちらの箱を見ているかわからないメガネ |
ArrayBufferLike は、「ArrayBuffer か SharedArrayBuffer のどちらか」という意味の型です。
エラーの意味
ここまでの前提知識を使うと、エラーの意味がわかります。
TypeScriptの型定義(lib.dom.d.ts)では、applicationServerKey に渡せるものが次のように決められています。
applicationServerKey?: BufferSource | string | null;
type BufferSource = ArrayBufferView<ArrayBuffer> | ArrayBuffer;
ArrayBufferView<ArrayBuffer> は、「普通の箱を見ているメガネ」のことです(Uint8Array<ArrayBuffer> もここに含まれます)。まとめると、applicationServerKey に渡せるのは次の3つです。
- 文字列(
string) - 普通の箱そのもの(
ArrayBuffer) -
普通の箱を見ているメガネ(
Uint8Array<ArrayBuffer>など)
ところが、今回渡したのは Uint8Array<ArrayBufferLike>、つまりどちらの箱を見ているかわからないメガネでした。特別な箱(SharedArrayBuffer)を見ている可能性があるため、TypeScriptがエラーを出していました。
冒頭のエラーメッセージを1行ずつ読むと、次のように言い換えられます。
| 行 | 言い換え |
|---|---|
| 1行目 | 渡した値(Uint8Array<ArrayBufferLike>)は、applicationServerKey に渡せるものに当てはまりません |
| 2行目 | 渡した値は、「普通の箱を見ているメガネ」(ArrayBufferView<ArrayBuffer>)ではありません |
| 3行目 | メガネが見ている箱(buffer)の種類が合いません |
| 4行目 | 「どちらかわからない箱」(ArrayBufferLike)は、「普通の箱」(ArrayBuffer)とは言い切れません |
原因:< > を省略すると Uint8Array<ArrayBufferLike> になる
TypeScriptでは、< > を省略して Uint8Array とだけ書くと、Uint8Array<ArrayBufferLike> と書いたのと同じ意味になります。関数の引数に省略時の値(デフォルト値)を決めておけるのと同じで、< > を省略したときは ArrayBufferLike が使われるように決められているためです。
function urlBase64ToUint8Array(base64String: string): Uint8Array {
// Uint8ArrayはUint8Array<ArrayBufferLike> と同じ意味
戻り値の型は、関数が返す値に貼る「ラベル」のようなものです。関数の中で普通の箱を見ているメガネを作っても、戻り値に「どちらの箱を見ているかわからないメガネ」というラベルを貼った時点で、関数の外からはそのラベルどおりに扱われます。
そのため、関数の中身をいくら書き換えても直りませんでした。
対応:戻り値の型を Uint8Array にする
戻り値の型に <ArrayBuffer> を付けて、「普通の箱を見ているメガネ」だと明示します。関数の中身は Uint8Array.from() のままで問題ありません。
// 戻り値の型を Uint8Array<ArrayBuffer> と明示する
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = atob(base64);
return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0)));
}
これでエラーが消えました。
書き方ごとの結果
本当に戻り値の型が原因なのかを確かめるため、TypeScript 5.9.3で書き方ごとにコンパイルしてみました。
| 戻り値の型 | 関数の中身 | 結果 |
|---|---|---|
Uint8Array |
Uint8Array.from(...) |
エラー |
Uint8Array |
new Uint8Array(長さ) |
エラー |
Uint8Array |
new ArrayBuffer() を使って作る |
エラー |
Uint8Array<ArrayBuffer> |
Uint8Array.from(...) |
OK |
| 書かない | Uint8Array.from(...) |
OK |
| 書かない | new Uint8Array(長さ) |
OK |
戻り値の型を Uint8Array と書いたものは、中身に関係なくすべてエラーになりました。
戻り値の型を書かなかった場合は、TypeScriptが関数の中身から型を自動で判断(推論)してくれます。Uint8Array.from() や new Uint8Array(長さ) の結果は Uint8Array<ArrayBuffer> と判断されるため、エラーになりません。
戻り値の型を書かなくても動きますが、関数が何を返すのかがひと目でわかるように、Uint8Array<ArrayBuffer> と明示しておくのがおすすめです。
別の解決方法:文字列のまま渡す
型定義を見るとわかるとおり、applicationServerKey には文字列(string)も渡せます。MDNでも、Base64でエンコードした文字列を渡せると説明されています。文字列のまま渡せば Uint8Array に変換しないので、このエラー自体が起きません。
ただし、筆者は文字列のままでiPhoneで動くかは確かめていないため、この記事では変換する場合の直し方を紹介しています。
まとめ
- VAPID公開鍵の正体は65バイトのバイナリデータで、普段はbase64urlで文字列にして扱っている
-
ArrayBufferはバイナリデータをしまう「箱」、Uint8Arrayは箱の中身を数字の配列として扱う「メガネ」 -
Uint8Arrayは、< >を省略するとUint8Array<ArrayBufferLike>(どちらの箱を見ているかわからないメガネ)という意味になる -
applicationServerKeyにUint8Arrayを渡す場合、型の上ではUint8Array<ArrayBuffer>(普通の箱を見ているメガネ)でないと受け付けない - 戻り値の型を
Uint8Array<ArrayBuffer>にすれば解決する。関数の中身を変える必要はない
おわりに
エラーが出た行(applicationServerKey のところ)や関数の中身ばかりを見ていて、1行目の戻り値の型が原因だと気づくまでに時間がかかりました。
また、そもそも変換を始めたきっかけも、確かめていない推測でした。AIの回答も含めて、公式のドキュメントで裏付けを取ってから対応することの大切さを学びました。
