TypeScriptのtuple(タプル)は、useState の戻り値や Promise.all の結果として日常的に触れているのに、いざ自分で型を書く側に回ると配列との違いが急にあいまいになります。

[string, number] って、結局 (string | number)[] と何が違うんだ?
型を付けたはずのtupleが、いつの間にかただの配列になってる…なぜ?
多値返却って、tupleとオブジェクトのどっちで返すのが正解なんだろう…

tuple(タプル)は「要素の個数と順番が型で決まった配列」です。ただし型が守ってくれる範囲には明確な境界があり、そこを知らないまま使うと、型を付けたはずのデータがいつの間にか崩れます。この記事では、型が守る範囲と守らない範囲を切り分けたうえで、目の前のデータをどう型付けするか自分で判断できるところまで整理します。

この記事は次のような方におすすめです。

この記事はこんな人におすすめ!
  • 型注釈や配列型は書けるけれど、tupleとの使い分けに迷っている方
  • useStateやPromise.allの戻り値の型が読み解けずに詰まった方
  • 関数の多値返却を、tupleで返すかオブジェクトで返すか決めきれない方
  • tupleに型を付けたのに、実行時に長さが変わって驚いた経験がある方

読み終える頃には、tupleの書き方だけでなく「型が守ってくれない穴」の位置まで見えるようになり、tuple・配列・オブジェクトのどれを使うかを根拠を持って判断できるようになります。

それでは、順を追って詳しく見ていきましょう!

tupleとは(要素ごとに型と順番が決まった配列)

tupleは、要素の個数が固定され、位置ごとに型を指定できる配列型です。[string, number] と書けば「0番目はstring、1番目はnumber、要素はちょうど2つ」という意味になります。位置ごとの型は違っていても同じでもよく、[string, string] のように同じ型を並べても立派なtupleです。TypeScript Handbookでも、決まった個数の要素を位置ごとの型とあわせて表す配列型として説明されています(TypeScript Handbook「Object Types」Tuple Types)。

大事なのは、tupleはTypeScriptの型レベルにしか存在しない概念だという点です。JavaScriptにtupleという実体はなく、コンパイル後に残るのはただの配列です。tupleが与えてくれるのは「型の上の約束」であって、実行時に個数や順番を守らせる仕組みではありません。

配列とtupleの違いは、型が違うかどうかではなく、長さと位置の扱いにあります。配列は「同じ型の要素がいくつでも」入る可変長の入れ物、tupleは「個数が固定され、位置ごとに型が決まった」入れ物です。位置ごとの型は [string, number] のように違っても、[string, string] のように同じでもかまいません。

tuple型の基本の書き方と型注釈の効き方

宣言は型注釈に角括弧を並べるだけです。位置と型が1対1で対応します。

let user: [string, number] = ["taro", 30];

同じデータをunion型の配列で書くと、型としての制約が一気にゆるくなります。

let loose: (string | number)[] = ["taro", 30];

loose = [30, "taro"];     // 通る:順番が入れ替わっても代入できる
loose = ["a", "b", "c"];  // 通る:個数が変わっても代入できる
loose = [];               // 通る:空でも代入できる

(string | number)[] が言っているのは「stringかnumberが何個か入っている」だけなので、順番も個数も守られません。対してtupleは、位置ごとの型と要素数の両方を型の一部として持ちます。

もう一点、user の例で効いているのは型注釈だという事実を押さえておきます。配列リテラルは既定ではtupleに推論されないため、["taro", 30] をtupleとして扱わせるには、型注釈を明示するか as const を付けるかのいずれかが必要になります。

tupleの特徴を比較表で整理

違いを観点ごとに並べると、tupleがどこまで守れて、どこから守れないのかがはっきりします。

観点 string[](配列) [string, number](tuple)
要素数 固定されない(可変長) 2個に固定
位置ごとの型指定 できない(全要素が同じ型) できる(位置ごとに指定)
代入時の個数・順番チェック されない される
インデックスアクセスの型 常に stringnoUncheckedIndexedAccess 有効時は string | undefined 位置ごとに確定(0はstring、1はnumber)
length の型 number リテラル型 2
push・popの可否 できる できてしまう(要素型のunionを受ける)
リテラルからの推論 ["a", "b"]string[] 自動では推論されない

表で目を引くのは、tupleの列にひとつだけ穴があることです。代入のときは個数を数えるのに、pushは止められません。ここがtupleの性質を理解するうえでの分かれ目になります。

配列は「同じ意味のものが何個でも」並ぶ入れ物、tupleは「違う意味のものが決まった順で」並ぶ入れ物です。

個数と順番の間違いはコンパイル時に止まる

同じ書き間違いを、配列型とtupleで並べてみます。

const loose: (string | number)[] = ["taro", 30, 1]; // 通る(個数も順番もチェックされない)

const u1: [string, number] = ["taro", 30, 1];
// 例:Type '[string, number, number]' is not assignable to type '[string, number]'.
//     Source has 3 element(s) but target allows only 2.
//    (TypeScriptバージョンにより文言が異なる場合あり)

const u2: [string, number] = ["taro"];
// 例:Source has 1 element(s) but target requires 2.

const u3: [number, string] = ["taro", 30];
// 例:Type 'string' is not assignable to type 'number'. 等の型不一致

エラー文言が「Source has 3 element(s) but target allows only 2.」と個数を数えて教えてくれるのは、型が要素数まで知っているからです。union型の配列では、["taro", 30, 1][30, "taro"] も等しく正しい値なので、個数や順番をチェックする根拠がありません。

ここで出るエラーは、実行して初めて気づくはずだったバグを前倒しで見つけたものです。tupleを選ぶということは、代入の時点で個数と順番を機械に数えさせるという選択になります。

tupleの要素にアクセスする(インデックスと分割代入)

tupleから値を取り出す方法は、インデックスと分割代入の2系統です。どちらも共通しているのは、取り出した時点で型が確定していることです。

union型の配列から取り出した値は string | number のまま出てくるので、使う前に毎回絞り込みが要ります。tupleならその手間が消えます。

インデックスアクセスと範囲外エラー

インデックスでアクセスすると、位置ごとに型が確定します。

const user: [string, number] = ["taro", 30];

const name = user[0];    // string
const age = user[1];     // number
name.toUpperCase();      // 絞り込みなしで呼べる

const len = user.length; // 型は 2(リテラル型)

user[2];
// 例:Tuple type '[string, number]' of length '2' has no element at index '2'.
//    (TypeScriptバージョンにより文言が異なる場合あり)

user[0]string になるのは、型が「0番目はstring」と知っているからです。union型の配列だと同じ書き方が通りません。

const loose: (string | number)[] = ["taro", 30];

const n = loose[0]; // string | number
if (typeof n === "string") {
  n.toUpperCase(); // 絞り込んで初めて呼べる
}

tupleの価値は、この絞り込みが要らなくなるところにあります。加えて user.length の型が number ではなくリテラル型の 2 になる点も、tupleが個数を型として持っている証拠です。範囲外の user[2] は、存在しない位置として型エラーになります。

分割代入で名前を付けて取り出す

位置で覚えるのがつらいなら、分割代入で名前を付けて取り出せます。

const user: [string, number] = ["taro", 30];

const [name, age] = user; // name: string / age: number
const [, onlyAge] = user; // 先頭を読み飛ばす

型注釈を一切書いていないのに namestringagenumber になります。tupleが実務で最も効くのはこの形で、Object.entries が返す [string, T][] の各要素も、Promise.all が渡した順にそのままの型で返す戻り値も、この分割代入を前提にした設計です。

ただし、分割代入で読みやすくなるのは受け取る側だけです。[string, number] という型そのものは「0番目が名前」だと一言も言っていないので、位置に意味を持たせる設計では、読み手が順番を覚えておく必要があります

読みやすく安全にするtupleの書き方(ラベル・readonly・オプショナル)

素の [string, number] には、使い込むほど気になる弱点が3つあります。

  • 型を見ても0番目が何を表すのか読めない
  • 代入したあとに中身を書き換えられる
  • 末尾の要素を省略できない

それぞれに対応する機能が、ラベル・readonly・オプショナル要素です。どれも型注釈に少し書き足すだけで使えます。

ラベル付きtupleで引数の意味を伝える

各要素に名前を書けます。TypeScript 4.0で入ったLabeled Tuple Elementsの記法で、4.0以降で使える書き方です(TypeScript 4.0 Release Notes「Labeled Tuple Elements」)。

type User = [name: string, age: number];

const u: User = ["taro", 30];

効き目が分かりやすいのは関数のrest引数です。ラベルなしだと引数リストが args_0args_1 のような無名の並びとしてエディタに表示されますが、ラベルを付けるとシグネチャが読める形になります。

function greet(...args: [name: string, age: number]) {
  const [name, age] = args;
  return `${name} (${age})`;
}

ここで誤解しやすいのが、ラベルの効力の範囲です。ラベルは可読性とエディタ補完のためのもので、型チェックにも実行時にも影響しません

const u2: User = ["taro", 30];

u2[0];    // string:取り出し方は位置のまま
u2.name;  // ラベル名ではアクセスできない(エラーになる)

以前のTypeScriptでは全要素にラベルが必要でしたが、TypeScript 5.2以降は一部だけラベルを付ける書き方も許可されています。ただし、読みやすさを考えるなら、必要に応じて一貫して付けるとよいでしょう。

書き換えを防ぐreadonly tuple

型の前に readonly を置くと、要素の書き換えが型で止まります(TypeScript Handbook「Object Types」readonly Tuple Types)。

const point: readonly [number, number] = [0, 0];

point[0] = 1;
// 例:Cannot assign to '0' because it is a read-only property.
//    (TypeScriptバージョンにより文言が異なる場合あり)

point.push(1);
// 例:Property 'push' does not exist on type 'readonly [number, number]'.

注目したいのは2つ目です。readonlyを付けると push というメソッドが型の上から消えます。書き換えを防ぐつもりで付けたreadonlyが、長さを崩す経路までまとめて塞いでくれます。

同じreadonly tupleは as const からも得られます。["taro", 30] as const の型は readonly ["taro", 30] になり、要素がリテラル型のまま固定されます。

ここで注意点があります。readonlyが守るのは1階層目だけです。要素がオブジェクトなら、その中身までは守られません。

const conf: readonly [{ name: string }] = [{ name: "taro" }];

conf[0] = { name: "bob" }; // 例:read-only property としてエラーになる
conf[0].name = "bob";      // 通る:要素の中身の書き換えは止まらない

「readonlyにすれば完全に不変」ではなく、「要素そのものの差し替えと長さの変更を止める」と理解しておくと、期待とのずれが起きにくくなります。

オプショナル要素で末尾を省略可能にする

要素名のうしろに ? を付けると、その要素を省略できます。

type Point = [x: number, y: number, z?: number];

const p2: Point = [1, 2];    // 2要素でも通る
const p3: Point = [1, 2, 3]; // 3要素でも通る

このとき length の型は固定値ではなく 2 | 3 のunionになります。省略できる分だけ、型が知っている個数も複数になるわけです。

const p: Point = [1, 2];

p.length;        // 型は 2 | 3

const z = p[2];  // number | undefined
z * 2;
// 例:'z' is possibly 'undefined'.
//    (TypeScriptバージョンにより文言が異なる場合あり)

オプショナル要素は「省略できる」と引き換えに undefined が型に含まれますp[2] をそのまま演算するとエラーになるので、z ?? 0 のような既定値の用意か、undefined かどうかの判定が必要です。

もうひとつ制約があります。オプショナル要素のうしろに必須要素は置けません。

type Bad = [x?: number, y: number];
// 例:A required element cannot follow an optional element.
//    (TypeScriptバージョンにより文言が異なる場合あり)

可変長tuple(rest要素)で「先頭は固定、あとは可変」を表す

「1つ目は必ずメッセージ、残りは数値がいくつでも」のような形は、rest要素で表せます。

type LogArgs = [message: string, ...details: number[]];

function log(...args: LogArgs) {
  const [message, ...details] = args;
  console.log(message, details);
}

log("start");          // OK:message だけ
log("count", 1, 2, 3); // OK:残りは数値がいくつでも
log(1, 2);
// 例:Argument of type 'number' is not assignable to parameter of type 'string'.
//    (TypeScriptバージョンにより文言が異なる場合あり)

先頭の型は固定したまま、それ以降の個数だけを自由にできます。(string | number)[] で受けると先頭がメッセージである保証が消えるので、「先頭は固定、あとは可変」を型で表したいときはtupleが向いています

この表現力を大きく広げたのがTypeScript 4.0のVariadic Tuple Typesで、4.0以降は型引数をそのままtuple内にスプレッドする書き方が可能になりました(TypeScript 4.0 Release Notes「Variadic Tuple Types」)。さらに4.2以降は、rest要素を先頭や中間にも置けるようになりました(TypeScript 4.2 Release Notes「Leading/Middle Rest Elements in Tuple Types」)。

type Concat<T extends unknown[], U extends unknown[]> = [...T, ...U];

type R = Concat<[string], [number, boolean]>; // [string, number, boolean]

[...T, ...U] のように書けるため、既存のtupleを組み合わせて新しいtupleを組み立てられます。ただし、...string[] のような可変長rest要素を複数並べることはできません。[...T, ...U] のようなgeneric tuple spreadとは区別して考えます。

tupleが崩れる落とし穴(推論とpush)

ここまでは型が守ってくれる話でした。ここからは守ってくれない話です。冒頭で触れた「tupleは型の上の約束であって、実行時のJavaScriptにはただの配列しかない」という性質が、そのまま2つの穴として現れます。

  • そもそもtupleに推論されない(tupleになる前の穴)
  • tupleにしたあとで長さが崩れる(tupleになった後の穴)

どちらも「エラーが出ないのに壊れる」タイプなので、知らないと気づけません。

配列リテラルはtupleに推論されない

型注釈を書かずに配列リテラルを置くと、tupleにはなりません。

const user = ["taro", 30];
// 推論結果は (string | number)[]。[string, number] にはならない

const [name, age] = user;
name.toUpperCase();
// 例:Property 'toUpperCase' does not exist on type 'string | number'.
//    (TypeScriptバージョンにより文言が異なる場合あり)

const で宣言しているのに型が広がってしまうのが、つまずきやすいところです。const が防ぐのは変数への再代入だけで、中身の型が広がるのは止められませんuser という束縛が変わらないことと、["taro", 30] の型が [string, number] に絞られることは別の仕組みだからです。

実害が出やすいのは関数の多値返却です。return [name, age]; と書いた関数の戻り値は (string | number)[] に推論され、呼び出し側で分割代入した変数がすべてunionになります。

tupleとして固定する方法は2つです。

// ① 型注釈を明示する
const user1: [string, number] = ["taro", 30];

// ② as const で固定する
const user2 = ["taro", 30] as const; // readonly ["taro", 30]

as const を付けた値はreadonly tupleになり、要素の型も "taro"30 というリテラル型のまま保たれます(TypeScript 3.4 Release Notes「const assertions」)。

pushで長さが守られない

tupleにできた後にも穴があります。長さが固定されているはずのtupleに、push が通ります。

const t: [string, number] = ["taro", 30];

t.push("bob"); // エラーにならない
t.push(99);    // これもエラーにならない
t.push(true);
// 例:Argument of type 'boolean' is not assignable to parameter of type 'string | number'.
//    (TypeScriptバージョンにより文言が異なる場合あり)

正確に言うと、tupleに何でもpushできるわけではなく、要素型のunion(ここでは string | number)に含まれる値だけが通ります。tupleは配列型を土台にしているため push を受け継いでおり、その引数が要素型のunionとして解決されるからです。union外の boolean は弾かれます。

ここで型と実行時がずれます。

const t: [string, number] = ["taro", 30];
t.push("bob");

console.log(t);        // ["taro", 30, "bob"] ← 実行時は3要素
console.log(t.length); // 3 ← 実行時は3。ただし型の上では 2 のまま

t[2];
// 例:Tuple type '[string, number]' of length '2' has no element at index '2'.
//    (TypeScriptバージョンにより文言が異なる場合あり)

型は「2要素」と言い続け、実行時には3要素目が存在するという状態になります。しかも t[2] は読もうとすると型に拒まれるので、実際にある値に型経由では触れません。型を信じてコードを書くほど、実態とのずれが大きくなります。

対策は readonly です。readonly [string, number] にすれば push というメソッドが型の上から消えるため、この穴自体が塞がります。書き換える予定がないtupleには最初からreadonlyを付ける、という運用にしておくと、pushの存在を意識せずに済みます。

tupleを使う場面・使わない場面

tupleが向くのは、位置と意味が1対1で固定されているデータです。

  • 関数の多値返却:Reactの useState が返す [value, setValue] が代表例
  • Promise.all の戻り値:渡した順にそのままの型で並ぶ
  • キーと値のペア:Object.entries が返す [string, T][] の各要素
  • 座標 [x, y] やRGB [r, g, b]:順番が一般的に共有されているもの

いずれも、名前を付けるまでもなく順番を読み取りやすいケースです。とくにReactで使用される useState は、実務で最も頻繁に触れるtupleのひとつです。

逆に避けたいのは次のような場面です。

  • 要素が増えて、何番目が何なのか読み取れなくなっている
  • 将来的に要素が増えそうで、増えるたびに呼び出し側の分割代入を直すことになる
  • 呼び出し側が順番を間違えても、型が同じなので気づけない

3つ目が特に危険です。[string, string] で「名前」と「メールアドレス」を返すと、入れ替えて受け取ってもコンパイルは通ります。位置でしか区別できないということは、同じ型が並んだ瞬間にチェックが効かなくなるということです。

目安としては、要素が3つを超えるか、位置の意味が自明でないならオブジェクトにすると扱いやすくなります。オブジェクトならプロパティ名が意味を持つので、順番を間違える経路そのものが消えます。ただしこれは判断の目安であって、絶対の規則ではありません。座標の [x, y, z] のように、3つを超えても順番が自明なデータは十分あります。

判断フロー:tuple・配列・オブジェクトのどれを選ぶか

目の前のデータをどれで型付けするかは、上から順に問いに答えれば決まります。

最初の分岐で配列を選ぶのは、ユーザー一覧やタグの集まりのように「同じ意味のものが何個でも」並ぶデータです。個数が決まっていても、全要素が同じ意味なら配列のままで困りません。

2つ目の分岐で問うているのは「順番が読み手の常識になっているか」です。[x, y] は説明不要でも、[name, email] は呼び出し側に順番の記憶を要求します。読み手が順番を思い出さなければ使えないなら、それはtupleではなくオブジェクトの仕事です。

このフローでtupleを選ぶなら、ラベルを付けて意味を書き残し、書き換えないものにはreadonlyを付けます。ここまでやると、tupleは「読める・崩れにくい」型になります。オブジェクトを選ぶ場合は、プロパティ名で意味を持たせる形に切り替えます。

【付録】さらに学びを深めるためのリソース


さらにTypescriptの学習を進めたい方のために、いくつかのリソースを紹介します。
これらのリソースを活用することで、TypeScriptの型システムについてより深い知識を得ることができるでしょう。

おすすめの書籍

ゼロからわかる TypeScript入門


技術評論社から出版されている「ゼロからわかる TypeScript入門」は、プログラミング初心者や本職プログラマーではない方を主な対象にした入門書です。

変数・条件分岐・ループといった基本から、クラスやインターフェース、モジュールまで段階的に学べる構成になっています。最終章ではWeb APIとJSONを使った非同期Webアプリの作成も体験できるので、「実際に動くものを作る」ところまで到達できます。

プロを目指す人のためのTypeScript入門


技術評論社の「プロを目指す人のためのTypeScript入門 安全なコードの書き方から高度な型の使い方まで」、通称 ブルーベリー本 です。
JavaScriptの仕様とTypeScript独自の機能を両方押さえつつ、リテラル型・ユニオン型・keyof型・ジェネリクスなど、高度な型表現まで踏み込んで解説しています。TypeScriptの型システムの表現力を本格的に学べる一冊です。

オンラインで参照できる公式ドキュメント

TypeScript公式ハンドブック


https://www.typescriptlang.org/docs/
TypeScriptの公式ドキュメントです。
intersection型を含む、すべての型システムの機能について詳細な説明があります。

TypeScript Deep Dive


https://basarat.gitbook.io/typescript/
TypeScriptの深い部分まで掘り下げて解説しているオンラインブックです。
無料で読むことができ、intersection型についても詳しく説明されています。

TypeScriptの学習は終わりがありません。
新しい機能が常に追加され、より良い書き方が発見されています。
継続的に学習を続けることで、より良いTypeScriptプログラマーになれるはずです。


まとめ – tupleは型の上の約束、守れる範囲と守れない範囲を押さえる

この記事の要点をまとめます。

  • tupleは「個数と順番が型で決まった配列」。配列が「同じ意味のものが何個でも」なのに対し、tupleは「違う意味のものが決まった順で」並ぶ
  • 型が守ってくれるのは代入時の個数・順番チェック、位置ごとのインデックスアクセス、分割代入までで、実行時はただの配列。要素型のunionに含まれる値なら push が通ってしまい、配列リテラルは自動ではtupleに推論されない
  • だからtupleを使うなら、ラベルで意味を書き残す/readonly(または as const)で穴を塞ぐまでをセットにする
  • 要素が3つを超えるか、位置の意味が自明でないならオブジェクトへ切り替える

tupleを「便利な短い配列」として使うと崩れやすくなりますが、「型の上の約束」として範囲を分かったうえで使えば、多値返却もペアのデータも安全に扱えます。ラベルとreadonlyを足す一手間が、そのまま型の信頼度になります。


※本記事の本文案はAIを活用して作成していますが、記載している内容およびコードは筆者が実際に調査、検証・実行し、内容の正確性を確認した上で公開しています。