TypeScript基礎講座 #5 関数の型

読了 10分

前回はunion/literal/narrowingを扱いました。今回は関数の型を精密に表現する道具を整理します — オプショナル/デフォルト引数、関数シグネチャ、オーバーロード、そしてジェネリクスとの初対面まで。

関数シグネチャの復習 #

基本形からもう一度整理:

関数シグネチャ
function add(a: number, b: number): number {
  return a + b;
}
  • 仮引数の型: a: numberb: number
  • 戻り値の型: : number

戻り値の型は推論がうまくいくのでよく省略します(単純な関数なら)。ただし明示すると意図がはっきりし、関数の本体が意図と違うものを返すと即座に捕まります。

戻り値の型推論
function add(a: number, b: number) {
  return a + b;       // 戻り値の型がnumberに自動推論
}

function add(a: number, b: number) {
  return String(a + b);   // 戻り値の型がstringに推論される — 意図と違うと気づきにくい
}

大規模なコードベースでは関数シグネチャに戻り値の型を明示する規約もあります。推論だけに頼ると関数の本体で型が少し変わっても気づきづらいからです。

アロー関数と関数式 #

関数宣言と同じように型を明示できます。

アロー関数
const add = (a: number, b: number): number => a + b;
関数式
const subtract = function(a: number, b: number): number {
  return a - b;
};

この2つはJavaScript側の違いであり、TypeScript側の扱いはほぼ同じです。

関数型自体に別名 (#3 復習) #

関数のシグネチャをtype aliasで別名を付けて再利用するパターンはよく登場します。

関数型の別名
type BinaryOp = (a: number, b: number) => number;

const add: BinaryOp = (a, b) => a + b;
const multiply: BinaryOp = (a, b) => a * b;

BinaryOpの別名のおかげで:

  • addmultiplyの定義が短くなる(個別の仮引数の型を書かなくてよい)
  • 同じシグネチャを複数の箇所で一貫して表現
  • コールバック仮引数が多いライブラリAPIに非常に便利

オプショナル仮引数 #

?を付けると渡さなくても済みます。

オプショナル
function greet(name: string, greeting?: string): string {
  if (greeting) {
    return `${greeting}, ${name}!`;
  }
  return `こんにちは, ${name}!`;
}

greet('太郎');           // ✓ 'こんにちは, 太郎!'
greet('花子', 'ようこそ');   // ✓ 'ようこそ, 花子!'

オプショナルな仮引数は常に必須仮引数の後に来る必要があります。

🚫 オプショナルが先頭
function bad(greeting?: string, name: string): string {  // エラー: 必須がオプショナルの後ろ
  // ...
}

オプショナル仮引数はT | undefined型として扱われます。関数の中でgreetingを使うときはundefinedの可能性を意識する必要があります(#4のnarrowing)。

デフォルト仮引数 #

値を渡さなかったときに使う初期値を指定できます。この場合、オプショナル表記(?)は外す必要があります。

デフォルト値
function greet(name: string, greeting: string = 'こんにちは'): string {
  return `${greeting}, ${name}!`;
}

greet('太郎');           // 'こんにちは, 太郎!'
greet('花子', 'ようこそ');   // 'ようこそ, 花子!'

デフォルトのある仮引数は自動的にstring型に推論されます(undefinedが入ってくるとデフォルトが埋めるからです)。

Rest仮引数 #

JavaScriptの...restは同じ型の可変長引数をすべて受け取ります。

rest
function sum(...numbers: number[]): number {
  return numbers.reduce((acc, n) => acc + n, 0);
}

sum(1, 2, 3);           // 6
sum(1, 2, 3, 4, 5);     // 15

...numbers: number[]は「0個以上のnumberを集めて配列に」という意味です。

複数の型を混ぜたいならunionやタプルを活用します:

rest with union
function logAll(...args: (string | number)[]): void {
  args.forEach(a => console.log(a));
}

logAll('a', 1, 'b', 2);

関数のthis型 (まれに使用) #

関数の中でthisの型を明示したいときは、最初の仮引数のように書きます。

this型
function clickHandler(this: HTMLButtonElement, event: MouseEvent): void {
  console.log(this.textContent);  // thisがHTMLButtonElementだとわかる
}

this仮引数は呼び出し時に引数として渡されません — 型情報を表すためだけのものです。クラスやオブジェクトメソッドを扱うときにたまに使います。

関数オーバーロード #

同じ名前の関数が異なるシグネチャを持てるようにします。仮引数の種類によって動作が変わる関数を表すときに使います。

オーバーロード
// シグネチャの宣言
function getValue(key: string): string;
function getValue(key: number): number;

// 実際の実装
function getValue(key: string | number): string | number {
  if (typeof key === 'string') {
    return `key は文字列: ${key}`;
  }
  return key * 2;
}

const a = getValue('hello');   // 型: string
const b = getValue(42);         // 型: number

上の2つのfunction getValue(...)の宣言がオーバーロードシグネチャで、下の1つが実装です。外部から呼び出すときはオーバーロードシグネチャのいずれかにマッチする必要があり、実装シグネチャは外部に公開されません。

オーバーロードは強力ですが少し重い道具なので、できればunion型やジェネリクスで表現するほうがすっきりすることが多いです。本当に仮引数の種類によって戻り値の型が異なる場合だけオーバーロードを使ってください。

ジェネリクス — 型を変数のように扱う #

次の関数を見てください。入力をそのまま返す単純な関数です。

単純な関数
function identityNumber(value: number): number {
  return value;
}

function identityString(value: string): string {
  return value;
}

同じことをするのに型ごとに別々に書くのは不自然ですね。ジェネリクスを使えば1つの関数で表現できます。

ジェネリクスのidentity
function identity<T>(value: T): T {
  return value;
}

const a = identity<number>(42);       // T = number, 戻り値 number
const b = identity<string>('hello');  // T = string, 戻り値 string

// 普通は明示しなくても推論される
const c = identity(42);               // Tがnumberに推論される
const d = identity('hello');          // Tがstringに推論される

<T>型変数です。関数が呼び出されるときに実際の型に置き換えられる位置です。Tの位置にnumberが入ると仮引数もnumber、戻り値もnumberになります。

名前は慣例としてT(Type)、UVのような1文字がよく使われますが、意味のはっきりした名前(TItemTKeyTValue)もよいです。

ジェネリクスの真価 — コレクション関数 #

ジェネリクスはコレクション処理関数で輝きます。配列の最初の項目を返す関数:

最初の項目を返す関数
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

const a = first([1, 2, 3]);              // a: number | undefined
const b = first(['x', 'y']);             // b: string | undefined
const c = first([{ id: 1 }, { id: 2 }]); // c: { id: number } | undefined

同じ関数が、入力の配列に応じて適切な戻り値の型を推論してくれます。any[]で受け取っていたら戻り値の型もanyになって型情報が消えていたところを、ジェネリクスのおかげで精密に保たれます。

配列をマッピングする関数:

mapに似たもの
function map<T, U>(arr: T[], fn: (item: T) => U): U[] {
  const result: U[] = [];
  for (const item of arr) {
    result.push(fn(item));
  }
  return result;
}

const lengths = map(['a', 'bc', 'def'], s => s.length);
// lengths: number[]

型変数が2つ(TU)あるケースです。入力配列の型(T)と変換結果の型(U)が違うことがあるからです。

ジェネリクス + コールバックシグネチャ #

コールバックを受け取る関数をジェネリクスで書くと、非常に表現力のあるAPIを作れます。

フェッチのシミュレーション
async function fetchData<T>(url: string): Promise<T> {
  const res = await fetch(url);
  return await res.json() as T;
}

type User = { id: string; name: string };

const user = await fetchData<User>('/api/users/1');
// user: User

const users = await fetchData<User[]>('/api/users');
// users: User[]

呼び出し元がTを明示してレスポンスの形を宣言的に表現します。fetch関数自体は型に無関係に動作しますが、呼び出し時点で型を被せるわけです。

(注: 上のコードのas Tランタイム検証をしません。レスポンスが本当にその形かは私たちの責任です。実戦ではZodのような検証ライブラリを一緒に使います。)

制約 (extends) #

ジェネリクスの型に条件をかけられます。

制約 — lengthがある型だけ
function logLength<T extends { length: number }>(value: T): void {
  console.log(value.length);
}

logLength('hello');       // ✓ 文字列にはlengthがある
logLength([1, 2, 3]);     // ✓ 配列にもlengthがある
logLength({ length: 5 }); // ✓
logLength(42);            // 🚫 numberにはlengthがない

T extends { length: number }は「Tはlengthプロパティを持つ何らかの型」という制約です。制約のおかげで関数の本体でvalue.lengthを安全に呼べるようになります。

制約は#6でさらに深く扱います。

デフォルトの型 #

ジェネリクスの仮引数に初期値を与えることもできます。

ジェネリクスのデフォルト
function createList<T = string>(): T[] {
  return [];
}

const a = createList();            // T = string (デフォルト), 戻り値 string[]
const b = createList<number>();    // T = number, 戻り値 number[]

呼び出し元が型を明示しないとデフォルトが使われます。最もよくある使い道をデフォルトに置くパターンが自然です。

関数シグネチャをオブジェクトの中に入れる #

オブジェクトにメソッドのように関数を置くとき、2つの記法があります:

メソッドシグネチャ
type Counter = {
  increment(by: number): number;        // メソッド形式
};

type CounterAlt = {
  increment: (by: number) => number;    // アロー関数形式
};

ほぼ同じ意味ですが、微妙な違いがあります(strictFunctionTypesオプション下の変性チェック)。日常的にはどちらでも構いません。

自分で試す — 小さなユーティリティ #

ジェネリクスと関数の型を総合した小さなユーティリティ集です。

utils.ts
// 1. 2つのオブジェクトを合成して新しいオブジェクトを作る
function merge<A, B>(a: A, b: B): A & B {
  return { ...a, ...b };
}

const merged = merge({ name: '太郎' }, { age: 30 });
// merged: { name: string } & { age: number }
console.log(merged.name, merged.age);

// 2. 配列から条件に合う最初の項目を見つける
function findFirst<T>(arr: T[], predicate: (item: T) => boolean): T | undefined {
  for (const item of arr) {
    if (predicate(item)) return item;
  }
  return undefined;
}

const numbers = [1, 2, 3, 4, 5];
const evenFirst = findFirst(numbers, n => n % 2 === 0);  // 2

const users = [
  { id: 'u-1', name: '太郎' },
  { id: 'u-2', name: '花子' },
];
const me = findFirst(users, u => u.id === 'u-1');  // { id: 'u-1', name: '太郎' }

// 3. オブジェクトから特定のキーだけを選んで新しいオブジェクトを作る (簡易 Pick)
function pickKeys<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
  const result = {} as Pick<T, K>;
  for (const key of keys) {
    result[key] = obj[key];
  }
  return result;
}

const fullUser = { id: 'u-1', name: '太郎', email: 'taro@example.com', age: 30 };
const summary = pickKeys(fullUser, ['id', 'name']);
// summary: { id: string; name: string }

最後のpickKeysに登場したkeyof TPick<T, K>は#6と#7でさらに詳しく扱います。今は「こういうことができるんだ」くらいに見ておいてください。

よくある落とし穴 #

1. 仮引数の型推論失敗 #

アロー関数の仮引数の型が推論されないとき:

推論失敗
[1, 2, 3].forEach(item => {
  // item: number — うまく推論される
});

const fn = item => {
  // 🚫 itemがanyに推論される (`noImplicitAny`オプション下ではエラー)
};

関数がコールバックとして即座に渡されると外側のシグネチャから推論できますが、分けて変数に入れると推論が弱くなります。そのときは明示的に型を書く必要があります。

2. ジェネリクスの自動推論を信じすぎる #

推論が狭すぎる
function pickFirst<T>(arr: T[]): T | undefined {
  return arr[0];
}

const items = ['a', 'b', 'c'] as const;   // readonly ['a', 'b', 'c']
const first = pickFirst(items);
// first: 'a' | 'b' | 'c' | undefined  — 狭くなったのが意図と違うことがある

as constで作った配列をジェネリクス関数に渡すと、型が非常に狭く推論されます。意図通りなら良いのですが、そうでなければpickFirst<string>(items)のように明示します。

3. 関数仮引数の変性 #

コールバックの変性の落とし穴
type Handler = (event: MouseEvent) => void;

const h: Handler = (event: Event) => {
  // ✓ Eventを受け取る関数はMouseEventも受け取れる (より具体的でない型を受け取るのはOK)
};

const h2: Handler = (event: MouseEvent & { detail: number }) => {
  // 🚫 より具体的な型を要求すると互換性なし
};

関数仮引数の互換性ルールは最初は不慣れです。日常的に頻繁に出会うわけではないので、出会ったときに理解すれば十分です。

まとめ #

今回は関数の型を精密に表現する道具を扱いました。

  • 仮引数: オプショナル (?)、デフォルト (= 値)、rest (...)
  • 関数シグネチャにtype aliasを付ける (type BinaryOp = (a, b) => number)
  • 関数オーバーロード — 仮引数によって戻り値の型が変わる場合
  • ジェネリクス入門<T>で型を変数のように扱う
  • 制約(T extends ...)とデフォルト(T = string)

ジェネリクスは少し触っただけですが、次回で本格的に入ります。「TypeScript基礎講座 #6 ジェネリクスを深く」では、制約をより精緻に活用する方法、複数の型仮引数、ジェネリクスのインターフェース/クラス、keyofのような強力な道具を扱います。一度にすべて覚える必要はなく、慣れる分だけコードの表現力が一緒に伸びていく領域です。

X