オブジェクト型の重箱の隅

TypeScript のオブジェクト型について比較的触れられる機会が少ないこと (重箱の隅) をただ集めただけの記事です. オブジェクト型やその周辺に明るくなりたい人, または任意の重箱の隅が好きな人向け.

挙動の確認は 2022-11-06 時点の最新版である 4.8.4 で行っています.

オブジェクト型

TypeScript におけるオブジェクト型 (object types) は, その名の通りオブジェクトに対応する型です. 例えば以下のようなものがオブジェクト型に含まれます.

// 空のオブジェクト
type Empty = {};

// プロパティやメソッドなどメンバーを持つことができる
type Human = {
  name: string;
  greet(): void;
};

プリミティブ値との関係

JavaScript では undefinednull を除く全てのプリミティブ値に対して, あたかもオブジェクトであるかのようにプロパティやメソッドの参照ができます. TypeScript のオブジェクト型もこのことを反映して, undefinednull を除くプリミティブ値に対応する型 (number, string など) がオブジェクト型の部分型となっています. したがって, 以下のようなコードも型検査を通過します1.

// 数値型 (number) は {} の部分型なので OK
const x: {} = 42;

また TypeScript 4.8 以降は unknown (任意の型の上位型) と {} | undefined | null がほぼ同じ意味になりましたが, ここからも {}undefinednull を除くプリミティブ値も全て含んでいることがわかると思います.

別の「オブジェクト」型

紛らわしいことに, TypeScript には object という名前の型があります. これはこの記事で紹介するオブジェクト型とは異なり, プリミティブ値を含まない真のオブジェクトのみを表す型で, 非プリミティブ型 (non-primitive type) と呼び分けるのが一般的です.

const x: object = 42;
//    ~
// Type 'number' is not assignable to type 'object'. (2322)

さらに紛らわしいことに Object という名前の型もあります. こちらは Object.prototype という値のための型で, ふつうに生活していれば一生使うことはないでしょう.

空のオブジェクト型

空のオブジェクト型 {} はまったくメンバーを持たないように見えますが, 実は Object.prototype から継承したプロパティやメソッドについてはアクセスすることができます.

例えば以下のようなコードは型検査を通過します.

declare const x: {};

x.toString();
x.hasOwnProperty("foo");

一方で, 本当に Object.prototype から継承したメソッドを呼び出すべき場面は少ないですし, さらにはメソッドが上書きされている可能性もあるため, こういったコードを積極的に書くことは推奨しません.

const x: {} = {
  hasOwnProperty: 42,
};

x.toString();
// = "[object Object]"
// この文字列が得られても基本的には使い道がない

x.hasOwnProperty("foo");
// → 実行時エラー (TypeError: x.hasOwnProperty is not a function)

このような見えないメンバーについては後述の keyof などでも列挙されません.

interface を使ったオブジェクト型の定義

オブジェクト型は interface を使って定義することもできます. 例えば上の Humaninterface を使って定義すると以下のようになります.

interface Human {
  name: string;
  greet(): void;
}

type Human = ... のように型エイリアスを使ってオブジェクト型に名前を付けた場合との違いはいくつかありますが,

くらいを覚えておけば良いでしょう.

class を使ったオブジェクト型の定義

class 宣言によってもオブジェクト型が定義されます. 定義される型は interface とほぼ同じですが,

  • 定義のマージは行われない
  • privateprotected といったアクセス修飾子を持ったメンバーを含むことができる

といった違いがあります.

オブジェクト型のメンバー

オブジェクト型にメンバーとして含められるものには以下の種類があります.

  • プロパティ
  • Index signatures
  • メソッド
  • Call signatures
  • Construct signatures

プロパティ

オブジェクト型のプロパティは, その名の通りオブジェクトにプロパティが存在することを表します.

type T = {
  foo: number;
  bar: string;
  greet: () => void;
};

declare const x: T;
const foo: number = x.foo;
x.greet();

プロパティや後述のメソッドのキーは computed property keys の形で記述することもできます. 特に Symbol をキーに使ったプロパティやメソッドについてはこの方法でしか定義できません.

type T = {
  ["foo"]: number;
  [Symbol.iterator]: () => Iterator<string>;
};

よく見ると実は [...] に入るのは型レベルではなく値レベルの式 (の一部) というところが地味に注意すべきポイント.

Index Signatures

辞書のように任意のキーを持つオブジェクトのプロパティは index signatures を使って書くことができます.

type T = {
  [key: string]: number | undefined;
  [key: symbol]: string | undefined;
};

declare const x: T;
const foo: number | undefined = x.foo;
const itr: string | undefined = x[Symbol.iterator];

[key: T] のうち key の部分は単なるラベルで, 機能的な意味はありません. また T には string | number | symbol の部分型のみ指定が可能です.

キーが stringnumber の index signature を両方定義することも可能ですが, JavaScript のオブジェクトにおいて数値によるアクセス x[0] が文字列によるアクセス x["0"] と等価なことを反映して, キーが number の index signature の型は, キーが string の index signature の型の部分型である必要があります.

type T = {
  [key: string]: number | undefined;
  [key: number]: string | undefined;
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// 'number' index type 'string | undefined' is not assignable to 'string' index type 'number | undefined'.(2413)
};

プロパティとそれを包含する index signature の両方が存在する場合も, プロパティの型が index signature の部分型である必要があります.

type T = {
  [key: string]: number | undefined;
  foo: string;
// ~~
// Property 'foo' of type 'string' is not assignable to 'string' index type 'number | undefined'.(2411)
};

なお交差型を使った場合は index signature と互換性のないプロパティの共存もできて, この場合はアクセスするとプロパティの方が優先されます.

type T = {
  [key: string]: number | undefined;
} & {
  foo: string;
};

declare const x: T;
const foo: string = x.foo;
const bar: number | undefined = x.bar;

Object.prototype から継承したメンバーと index signature のどちらが優先されるかについては空気読みが発生して, アクセスの方法に依存します. . によるアクセスでは前者, [] によるアクセスでは後者です.

type T = {
  [key: string]: number | undefined;
};

declare const x: T;
// . によるアクセスでは Object.prototype が優先
const a: () => string = x.toString;
// [] によるアクセスでは index signature が優先
const b: number | undefined = x["toString"];

ところで index signature にはいくつか型安全性の問題があり, かつ現代においては辞書や集合を表すのにより適した MapSet もあるので, あえて使うのは JSON をそのまま表現する場合などに限られるでしょう.

メソッド

オブジェクト型にはプロパティとは別にメソッドを含めることができます.

type T = {
  greet(): void;
};

プロパティとして関数を定義した場合とメソッドの違いはそれぞれの引数についての変性 (variance) の違いで, プロパティの場合は反変 (contravariant) ですが, メソッドの場合は双変 (bivariant) となります.

例えばプロパティの場合, 以下のように numbernumber | string の部分型であるため ComparableP<number | string>ComparableP<number> の部分型ですが, 逆の関係は成り立ちません.

type ComparableP<T> = {
  compare: (arg: T) => number;
};

declare const p1: ComparableP<number>;
const p2: ComparableP<number | string> = p1;
//    ~~
// Type 'ComparableP<number>' is not assignable to type 'ComparableP<string | number>'.
//   Type 'string | number' is not assignable to type 'number'.
//     Type 'string' is not assignable to type 'number'.(2322)

// 確かに p1 は引数に string が与えられることを想定していないので,
// p2: ComparableP<number | string> に代入ができると困る
p2.compare("fish");

メソッドの場合は同じように ComparableM<number | string>ComparableM<number> の部分型ですが, 逆に ComparableM<number>ComparableM<number | string> の部分型でもあります.

type ComparableM<T> = {
  compare(arg: T): number;
};

declare const m1: ComparableM<number>;
const m2: ComparableM<number | string> = m1; // エラーなし

// m1 も引数に string が与えられることを想定していないので困るが...?
m2.compare("fish");

なぜこんなことになっているかというと, readonly T[] などは型引数 T について共変 (covariant) なものとして扱いたいことが普通なのに, indexOf のようなメソッドの引数に T が登場していて2, 上のようにメソッドの引数を反変ではなく双変としないと readonly T[] 全体としては不変 (invariant) になってしまい, これだとあまりに不便なためです (実装時の PR も参考).

この双変性というのはメソッドの型を単体で取り出した場合にも残っていて, 一見すると普通の関数に見えて引数の変性が異なるということがあります.

type ComparableM<T> = {
  compare(arg: T): number;
};
declare const c1: ComparableM<number>["compare"];
const c2: ComparableM<number | string>["compare"] = c1; // エラーなし

ここまでの例からもわかるように双変性というのは型安全ではないため, それを理解した上でのハック目的3や, typescript-eslint の unbound-method ルールを使いたいといったことがなければ, あえてプロパティではなくメソッドを使う必要はないでしょう.

Call Signatures

オブジェクトが関数として呼び出せることを表すために, 他のメンバーに加えて call signatures を含めることもできます.

type T = {
  (arg: number): string;
};

declare const f: T;
const r: string = f(42);

上の例の型 T の定義は, 実は以下の関数型による定義と等価です.

type T = (arg: number) => string;

一つのオブジェクト型に複数の call signature を含めることもできて, この場合は関数がオーバーロードされていることを表します.

type T = {
  (arg: number): string;
  (arg: string): number;
};

この定義は以下と等価です.

declare function f(arg: number): string;
declare function f(arg: string): number;

type T = typeof f;

オブジェクト型が call signature または次に紹介するの construct signature を含む場合, 通常のオブジェクト型が Object.prototype から継承したプロパティやメソッドを暗黙的に持つのと同様に, Function.prototype から継承したプロパティやメソッドを暗黙に持つことになります. 以下は Function.prototype から継承されたメソッド call を呼び出している例です.

type T = {
  (arg: number): string;
};

declare const f: T;
f.call(undefined, 42);

Construct Signatures

Call signatures の亜種で, オブジェクトがコンストラクタ関数として new 演算子を使って呼び出せることを表すための construct signatures というものもあります.

type T = {
  new (arg: number): string;
};

declare const f: T;
const r: string = new f(42);

複数個書いた場合にオーバーロードの意味になるといった特徴は call signatures と同様です.

メンバーに対する修飾子

オブジェクト型のプロパティと index signatures については修飾子 (modifier) を付与することができます.

readonly 修飾子

プロパティと index signatures にはそれぞれ readonly 修飾子を付与することができます. これはその名の通りプロパティが読み取り専用であることを表していて, 代入して変更しようとしたりするとコンパイルエラーになります.

type T = {
  readonly foo: number;
  readonly [key: string]: number | undefined;
};

declare const x: T;

const foo: number = x.foo;
x.foo = 42; //
// ~~
// Cannot assign to 'foo' because it is a read-only property.(2540)

const bar: number | undefined = x.bar;
x.bar = 42;
// ~~
// ndex signature in type 'T' only permits reading.(2542)

ただし readonly 修飾子のない型へのキャストは無条件でできてしまう点には注意が必要です.

type T = {
  readonly foo: number;
};
type U = {
  foo: number;
};

declare const x: T;
const y: U = x;
y.foo = 42; // エラーなし

オプショナル修飾子

プロパティに対してオプショナル修飾子 ? を付与することで, そのプロパティが存在しないかもしれないことを表すことができます.

type T = {
  foo?: number;
};

デフォルト (strict のみ) の設定では, 上の例の foo には以下の 3 通りの可能性があります.

  • プロパティが存在して, 値は number のいずれか
  • プロパティが存在して, 値は undefined
  • プロパティが存在しない

exactOptionalPropertyTypes が有効な場合は以下の 2 つになります.

  • プロパティが存在して, 値は number のいずれか
  • プロパティが存在しない

しばしば foo: number | undefined の略記法と誤解されていますが, 上記の通り異なった意味を持ちます.

readonly とは異なり, index signatures には付与することができません. そもそも index signature 自体が存在しないかもしれないという意味を内包しているからでしょう.

いずれにせよ, index signatures と同様に型安全性の問題があるため, JSON の構造を表現したり, オプショナルなパラメータを表したりするといった場合を除いては使用しない方が無難です.

部分型関係

オブジェクト型同士の幅や深さといった部分型関係については特筆すべきことはないので省略.

その他にここまでの内容で部分型関係に関連して注目すべきことは,

  • undefined, null を除くプリミティブ型 (number, string など) はオブジェクト型の部分型である
  • メソッドは引数について双変であるため, 型安全でなくなっている
  • Index signatures とオプショナル修飾子については部分型判定に穴があり, 型安全でなくなっている

あたりでしょうか.

keyof によるメンバーの列挙

keyof によってオブジェクト型のキーを列挙した場合は以下のような挙動になります.

  • プロパティとメソッドはそのままキーが列挙されます
  • Index signatures はキーが string ならば string | number, キーが numbersymbol ならばそのまま numbersymbol となります
  • Call signatures と construct signatures は列挙されません
  • Object.prototypeFunction.prototype から継承されたメンバーは列挙されません

Mapped Types

Mapped types はキーの集合からオブジェクト型を作るための構文です.

type Keys = "foo" | "bar" | "baz";

type T = { [K in Keys]: boolean };
// = {
//   foo: boolean;
//   bar: boolean;
//   baz: boolean;
// }

見た目は index signatures に似ていますが, 上で見たようなオブジェクト型のメンバーの記述とは異なる機能です. 例えば複数の mapped type を { ... } の中に記述することはできませんし, 他のメンバーと並べて書くこともできません.

マップ元のキーの集合 (上の例での Keys の部分) は string | number | symbol の部分型である必要があります. またキーの集合に string, number, symbol が与えられた場合はプロパティではなく index signature に変換されます.

type T = { [K in string | number | symbol]: boolean };
// = {
//   [x: string]: boolean;
//   [x: number]: boolean;
//   [x: symbol]: boolean;
// }

プロパティごとの型定義 (: の右側) では, キーの集合に含まれる個別のキー (上の例での K の部分) を取り出して使うこともできます.

type Keys = "foo" | "bar" | "baz";

type T = { [K in Keys]: Uppercase<K> };
// = {
//   foo: "FOO";
//   bar: "BAR";
//   baz: "BAZ";
// }

また as を使うとキーを変換した上でオブジェクト型を作ることもできます.

type Keys = "foo" | "bar" | "baz";

type T = { [K in Keys as Uppercase<K>]: boolean };
// = {
//   FOO: boolean;
//   BAR: boolean;
//   BAZ: boolean;
// }

as でキーが never に変換されるとそのキーは消えます.

type Keys = "foo" | "bar" | "baz";

type T = { [K in Keys as K extends "foo" ? never : K]: boolean };
// = {
//   bar: boolean;
//   baz: boolean;
// }

オブジェクト型のキーを使った mapped type

オブジェクト型のキーを keyof で列挙した上で mapped type で別のオブジェクト型を作ることもできますが, この場合には特別扱いがあり, キーだけでなく修飾子も合わせて新しいオブジェクト型にコピーされます.

type Obj = {
  foo: number;
  readonly bar: string;
  baz?: number[];
};
type T = { [K in keyof Obj]: boolean };
// = {
//   foo: boolean;
//   readonly bar: boolean;
//   baz?: boolean;
// }

// キーを mapped type の外で列挙している場合は修飾子はコピーされない
type Keys = keyof Obj;
type U = { [K in Keys]: boolean };
// = {
//   foo: boolean;
//   bar: boolean;
//   baz: boolean;
// }

ただし, index signature の readonly 修飾子については (なぜか) コピーされません.

type Obj = {
  readonly foo: number;
  readonly [key: string]: number | undefined;
};

type T = { [K in keyof Obj]: boolean };
// = {
//   readonly foo: boolean;
//   [x: string]: boolean;
// }

またこの例で, もし mapped type の外で keyof Obj でキーを列挙した場合は string | number となるはずですが, 結果から分かるようにここにも特別扱いがされて, 元のキーである "foo"string が維持されています.

型引数のキーを使った mapped type

エイリアスの中で型引数のキーを keyof で列挙した上で mapped type を使った場合はさらなる特別扱いがあり, 配列やタプルがそのまま配列やタプルの形を保ちます.

以下の例では型エイリアスの型引数に対して mapped type を使った場合と, それをインライン展開した場合の結果を比較しています. 前者は配列の要素の型が boolean に変わっただけでそのまま配列になっていますが, 後者は配列のプロパティやメソッドも全て boolean に変換されてしまっています.

type A = number[];

type Boolify<T> = { [K in keyof T]: boolean };
type T = Boolify<A>;
// = boolean[]

type U = { [K in keyof A]: boolean };
// = {
//   [x: number]: boolean;
//   length: boolean;
//   toString: boolean;
//   ...
// }

なお MapSet などにはこういった特別扱いはありません.

また distributive conditional types と同様に, 型引数にユニオン型を与えた場合は, ユニオン型の各要素ごとに mapped type が適用されたような結果となります.

type A = { foo: number } | { bar: string };

type Boolify<T> = { [K in keyof T]: boolean };
type T = Boolify<T>;
// = { foo: boolean } | { bar: boolean };

// ユニオン型の要素に共通のメンバーがないので keyof A = never となり, 結果は空になる
type U = { [K in keyof A]: boolean };
// = {}

修飾子の付け外し

プロパティと同様に mapped types にも readonly? といった修飾子を付与することができます.

type Keys = "foo" | "bar" | "baz";

type T = { readonly [K in Keys]?: boolean };
// = {
//   readonly foo?: boolean;
//   readonly bar?: boolean;
//   readonly baz?: boolean;
// }

逆にオブジェクト型のキーに元々修飾子が付いていた場合は -readonly-? を使って外すこともできます.

type Obj = {
  foo: number;
  readonly bar: string;
  baz?: number[];
};

type T = { -readonly [K in keyof Obj]-?: boolean };
// = {
//   foo: boolean;
//   bar: boolean;
//   baz: boolean;
// }

型引数のキーを使った mapped types の場合, 配列 T[] やタプル [T, U] は,

  • readonly 修飾子を付与した場合は, 読み取り専用の配列 readonly T[] やタプル readonly [T, U] に変換される
  • ? 修飾子を付与した場合は, 各要素に | undefined が付与される

といった変換がされます. 修飾子を外した場合はこの逆です.

// 組み込みのユーティリティ型なので定義は不要
// type Readonly<T> = { readonly [K in keyof T]: T[K] };
// type Partial<T> = { [K in keyof T]?: T[K] };

type T = Readonly<number[]>;
// = readonly number[]

type U = Partial<number[]>;
// = (number | undefined)[]

MapSet にも ReadonlyMapReadonlySet がありますが, やはりこれらには配列のような特別扱いはありません.


  1. フロー解析による narrowing を行う上では型 {} が付いた値は常に truthy という扱いになっていて, これは 0 など反例があり矛盾してしまうこともあるのですが, それはまた別の話
  2. そもそも共変になるように注意深くメソッドの定義をするべきであって, そこがあまりよくないという話でもある
  3. React の型定義にある bivarianceHack など

TypeScript エラー処理パターン

M 年前にも N 年後にも人類は同じ話をしている.

まとめ

  • エラーの発生方法は throw と return に大別できる
  • throw には簡潔さ, return には明瞭さと型安全性といった特徴がある
  • どちらの方法がより適しているかはプログラムの規模, エラーの種類, ハンドリングの方法などが判断の材料になる
  • 実際にどちらの方法を使うかは上の判断材料と, フレームワークやプロジェクトのコーディング規約なども合わせて複合的に決めるのがよい

エラー発生方法の分類

まず前提として, 関数から呼び出し元にエラーを伝える方法は以下の 2 つに大別できます. 逆にこの記事ではこれ以上の具体的な方法についての議論はしません.

throw

エラーを throw して呼び出し元に伝える方法です. 例えば以下のようなものが当てはまります.

  • throw new Error("...")
  • Promise.reject(new Error("..."))

Promise の rejection については async 関数内の throw と同等なため, こちらに分類しています.

return

エラーも戻り値の一種として, return1 で呼び出し元に伝える方法です. 関数の戻り値の型は例えば以下のようなものになります.

  • T | undefined
  • { success: true; value: T } | { success: false; error: E }
  • Result<T, E>

非同期処理の場合は戻り値の型はそれぞれ Promise<T | undefined> など Promise でラップされた型になります.

方法ごとの特徴

ここでは以下の 3 つの観点から, それぞれの方法の特徴を見てみます.

  • 簡潔さ
  • 明瞭さ
  • 型安全性

簡潔さ

記法の簡潔さの観点では, return に比べて throw の方が優れています. さすがに言語組み込みの機構なので強いですね.

一つは大域脱出を行う場合で, return の場合は自分でバケツリレーをしてエラーを上位に伝播させてやる必要がありますが, throw の場合はそれを勝手にやってくれます.

もう一つは関数のシグネチャで, return の場合はエラーを関数のシグネチャに含める必要があり, さらにそれが呼び出し元の関数のシグネチャにも伝播していきますが, throw の場合はそういったことは起こりません. ただし, 次の「明瞭さ」の観点ではこの価値が逆転します.

明瞭さ

関数の発生させるエラーがどういったものであるか, という明瞭さの点では, throw に比べて return が勝ります.

throw の場合, ある関数がエラーを発生させるのかや, どういったエラーが発生し得るかは関数のシグネチャには全く含まれません. これは非同期処理の場合も同様で, 確かに Promise<T> はエラーが発生する可能性を内包してはいますが, エラーが発生しない非同期処理も Promise<T> で表されるため区別ができませんし, 発生するエラーの種類も分かりません.

return の場合は関数の戻り値の型を見れば, その関数がエラーを発生させるのかや, どういったエラーを発生させるのかを判断することができます.

型安全性

型安全性についても throw に比べて return の方が優れています.

まず throw の場合, エラーハンドリングには try { ... } catch (err) { ... } を使うことになりますが, この err の型は unknown (TypeScript 4.4 より前では any) です. もしエラーの具体的な内容を見てなんらかの処理を行いたいのであれば, 必然的に動的な型検査2をするか, または安全でないキャストを行うことになります.

Promise の rejection についてもほぼ同様で, promise.catch((err) => { ... }) とした場合の err の型は any です. Promise<T> にはエラーの型を表すパラメータはないので, これを回避する方法もありません3.

どちらの方法が適しているか

ここまでで throw は簡潔さ, return は明瞭さと型安全性でより優れていることがわかりました.

続いて以下の 3 つの軸が変化したときに, それぞれの方法の許容度がどのように変化するかを考えてみます.

  • プログラムの規模
  • エラーの種類
  • エラーハンドリングの方法

プログラムの規模

プログラムや開発チームの規模が小さい場合, あるいは小さく済ませたい場合は, 「簡潔さ」の重要性が相対的に高くなります. つまりこの場合はより簡潔な throw の許容度が高いため, return ではなく throw を使うという選択も妥当と言えます.

逆にプログラムや開発チームの規模が大きくなってくると, 「明瞭さ」や「型安全性」の重要性が相対的に高まってきます. こういった場合は throw よりも return の方が許容度が高いと言えるでしょう.

エラーの種類

エラーの種類は以下のように 2 つに大別できます4.

  • 発生が予期できて, プログラムでハンドリングされるべきエラー
  • 発生が予期されず, プログラムでハンドリングする必要のないエラー

前者はユーザー入力のバリデーションエラーや通信時のエラー, 後者は事前条件が満たされないといった契約に関するエラー (バグ) などが該当します.

前者のように, エラーがプログラムでハンドリングされるべきなのであれば, 適切なハンドリング処理が書かれやすくなるように「明瞭さ」の重要性が高くなります. また具体的なエラーの内容まで扱うのであれば「型安全性」の重要度も相当に高くなります. こういったエラーに対しては return の許容度が高いと言えるでしょう.

後者の場合はそもそもハンドリングする必要がないので, 「簡潔さ」の方が重要度としては高くなり, return はむしろ冗長に見えてきます. こちらののエラーについては throw の方が許容度が高そうです.

エラーハンドリングの方法

エラーの種類の節でも述べた通り, エラーハンドリング時にエラーの内容を具体的に扱いたい場合は「型安全性」が重要になってきます.

例えばユーザー入力のバリデーションのように, エラーの理由を具体的にフィードバックすべき場合については, 発生したエラーの具体的な内容を読み取ることになります. この場合は throw と比べてより型安全な return の方が許容度が高いと言えそうです.

一方で, 場合によってはエラーが発生したことさえわかれば十分ということもあります. 例えば API など外部との通信を伴う処理は, 経路上のさまざまな原因で失敗することがあります. これらの個別の原因はデバッグには役に立つかもしれませんが, プログラム中で詳細なハンドリングをする必要はほぼありません. こういった場合は「型安全性」の重要度が低いため, throw の許容度も高くなる言えます.

実際にどの方法を使うべきか

上のような許容度に基づいて throw と return のどちらがより適しているか判断できるようになったところで, 実際にどちらを使うべきかはフレームワークやプロジェクトのコーディング規約なども参考に複合的に決めるのがよいでしょう.

実際に私個人がどちらを使うべきかを考えた例をいくつか紹介しておきます:

  • 100 行程度の GitHub Actions や 300 行程度の AWS Lambda の関数5を書いたときは, 規模が小さいのでたとえ明瞭でなくても読解が容易なこと, エラーハンドリングはログを出す程度で具体的なエラーの内容にまで踏み込んだ処理はしないこと, 全体を通して使用するライブラリが throw を使った設計であったことから, 簡潔さを優先して throw を使いました
  • サーバーの実装 (1,000 行〜) では, ユーザーに HTTP 4xx を返すべきエラーについては, 具体的なステータスコードの決定を確実かつ型安全に行えるよう return, 5xx になるべきエラーについては発生し得る箇所も多いため, 簡潔さを取って throw といったように使い分けます. 多くのフレームワークでは throw しておくとフレームワーク側で 5xx エラーを返してくれたり, ログに残せたりするのも理由の一つです
  • 比較的大規模なフロントエンド (100,000 行〜) を設計したときは, 原則としてはエラーの種類に基づいて, ハンドリングすべきエラーは return, そうでないエラーは throw することとしました. ライブラリなどとの間にギャップがあれば適宜変換してギャップを埋めます. ただし文脈からエラーの発生がすでに明瞭で, かつエラーを型安全に扱う必要がない場合などについては, 簡潔さを優先して throw も許容しています

  1. 重箱の隅: 継続渡しスタイルの場合は受け取った継続の呼び出し
  2. 動的な型検査をしている場合は安全であると言えなくもないですが, 私個人としては静的に検査できる方法が第一の選択肢であるべきと考えています
  3. 仮にパラメータがあったとしてもあまり役に立たないことが多そうで, 例えば async 関数内で別の同期的な関数を呼び出しているとき, その中で throw されるかは現行の TypeScript の仕組み上推論ができないため, 多くの場合で any にならざるを得ないと思われる
  4. Java の検査例外の仕組みなんかも参考
  5. 実は JavaScript で書いたけど TypeScript で書いていても同じ判断をしていると思う