ログ、計測、再試行、権限確認、cache。これらは複数の処理へ横断的に加えたい一方、本来の業務処理へ直接書くと中心的な意図を埋もれさせます。共通helperへ切り出しても、呼び出す順序や例外処理を各所で再現すれば重複は残ります。
Decorator(デコレーター)は、元のobjectと同じinterfaceを保ちながら、外側から責務を追加する構造パターンです。JavaScriptでは高階関数を使うと小さく表現できます。本記事では記事保存処理へログ、再試行、timeoutを段階的に重ね、合成順序、キャンセル、テスト、失敗しやすい設計まで扱います。
なお、GoFのDecoratorパターンと、@decorator というECMAScript/TypeScriptの言語構文は関連する用途を持ちますが同義ではありません。この違いも後半で整理します。
横断処理が本体へ混ざった例
記事を保存する処理へログと再試行を直接追加すると、何を保存するかより、周辺処理の方が長くなります。
type Post = Readonly<{ id: string; title: string }>;
async function savePostBefore(post: Post): Promise<void> {
console.info("save:start", { postId: post.id });
let lastError: unknown;
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
await postRepository.save(post);
console.info("save:success", { postId: post.id, attempt });
return;
} catch (error) {
lastError = error;
console.error("save:failed", { postId: post.id, attempt, error });
}
}
throw lastError;
}
この関数を変更する理由は、保存仕様、ログ形式、再試行回数、再試行可能なエラーの判定と複数あります。別の publishPost に同じ機能を付けるとcopyが始まります。逆に共通関数へ無理にまとめると、処理ごとに異なる引数を受ける巨大helperになりがちです。
責務を分けるには、まず中心となる処理の契約を明確にします。キャンセルを伝播できるよう AbortSignal も引数に含めます。
type SavePost = (
post: Post,
options?: { signal?: AbortSignal },
) => Promise<void>;
const savePost: SavePost = async (post, options) => {
await postRepository.save(post, options?.signal);
};
Decoratorを正確に定義する
GoFのDecoratorは、Componentと同じinterfaceを実装するDecoratorが、内側のComponentを保持し、呼び出しの前後へ振る舞いを追加するパターンです。利用側から見ると、元のComponentと装飾後のobjectを同じように扱えます。DecoratorをさらにDecoratorで包めるため、継承で全組み合わせのsubclassを作らず、必要な責務を合成できます。
JavaScriptでは関数も値なので、SavePost を受け取り SavePost を返す高階関数が同じ構造を表せます。入力、戻り値、例外、キャンセルという契約を保つことが重要です。単に前後で何かを実行するwrapperが、元と異なる型や意味を返すなら交換可能ではありません。
ログDecoratorを作る
loggerを外から受け取り、保存処理の開始、成功、失敗を記録します。元のerrorを握りつぶさず再throwし、利用側の失敗契約を維持します。
type Logger = Pick<Console, "info" | "error">;
function withLogging(inner: SavePost, logger: Logger): SavePost {
return async (post, options) => {
logger.info("save:start", { postId: post.id });
try {
await inner(post, options);
logger.info("save:success", { postId: post.id });
} catch (error) {
logger.error("save:failed", { postId: post.id, error });
throw error;
}
};
}
装飾後も SavePost なので、呼び出し方法は変わりません。
const loggedSavePost = withLogging(savePost, console);
await loggedSavePost(
{ id: "post-1", title: "Decoratorを学ぶ" },
{ signal: new AbortController().signal },
);
loggerをmodule globalから直接読むのではなく引数にしたため、テストでは記録先を差し替えられます。また、記事本文やtokenのような機密情報を無条件にlogへ含めないこともDecoratorの責務です。汎用loggerだから安全、とは限りません。
再試行Decoratorを加える
再試行では、回数だけでなく「何を再試行してよいか」が重要です。認証失敗やvalidation errorまで繰り返しても成功しません。判定関数と待機関数を依存として受け取ると、方針を明示して決定的にテストできます。
type RetryOptions = Readonly<{
maxAttempts: number;
isRetryable: (error: unknown) => boolean;
wait: (attempt: number, signal?: AbortSignal) => Promise<void>;
}>;
function withRetry(inner: SavePost, options: RetryOptions): SavePost {
if (!Number.isInteger(options.maxAttempts) || options.maxAttempts < 1) {
throw new RangeError("maxAttempts must be at least 1");
}
return async (post, callOptions) => {
for (let attempt = 1; ; attempt += 1) {
try {
await inner(post, callOptions);
return;
} catch (error) {
if (
attempt >= options.maxAttempts ||
!options.isRetryable(error) ||
callOptions?.signal?.aborted
) {
throw error;
}
await options.wait(attempt, callOptions?.signal);
}
}
};
}
待機は AbortSignal を受け取り、利用者がキャンセルしたら直ちに止めます。次はbrowserとNode.jsで使える最小実装です。
function wait(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason);
const onAbort = () => {
clearTimeout(timer);
reject(signal?.reason);
};
const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, ms);
signal?.addEventListener("abort", onAbort, { once: true });
});
}
const retriedSavePost = withRetry(savePost, {
maxAttempts: 3,
isRetryable: (error) => error instanceof TypeError,
wait: (attempt, signal) => wait(100 * 2 ** (attempt - 1), signal),
});
再試行は副作用を重複させます。HTTP requestならidempotency key、DB操作ならtransactionや一意制約など、同じ処理が複数回実行されても壊れない設計が必要です。「通信エラーが返ったが、サーバー側では保存済み」という状況を無視してはいけません。
timeoutは処理を止められるか
Promiseを Promise.race へ入れるだけでは、負けた処理自体は止まりません。裏で通信や書き込みが続く可能性があります。内側へ AbortSignal を渡せる契約にして、timeout時にabortします。
function withTimeout(inner: SavePost, timeoutMs: number): SavePost {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
throw new RangeError("timeoutMs must be positive");
}
return async (post, options) => {
const timeoutController = new AbortController();
const timer = setTimeout(
() => timeoutController.abort(new Error("save timed out")),
timeoutMs,
);
const signal = options?.signal
? AbortSignal.any([options.signal, timeoutController.signal])
: timeoutController.signal;
try {
await inner(post, { signal });
} finally {
clearTimeout(timer);
}
};
}
AbortSignal.any を利用できない実行環境では、複数signalをまとめるhelperやpolyfillが必要です。また、内側のrepositoryがsignalを無視するなら、呼び出し側へtimeout errorを返せても、処理は中断されません。Decoratorが保証できる範囲を文書化します。
合成順序は振る舞いである
Decoratorは外側から内側へ呼び出し、結果は内側から外側へ戻ります。したがって、同じ3つを重ねても順序で意味が変わります。
const enhancedSavePost = withLogging(
withRetry(withTimeout(savePost, 2_000), {
maxAttempts: 3,
isRetryable: (error) => error instanceof TypeError,
wait: async () => {},
}),
console,
);
この順序ではログは全体で開始・成功・失敗を1回記録し、各attemptには2秒のtimeoutが掛かります。withLogging をretryの内側へ置けばattemptごとにlogが出ます。withTimeout を最外周へ置けば、待機を含む全attemptの合計時間を制限します。
withLogging
└─ withRetry
└─ withTimeout
└─ savePost
どれが正解かは監視要件です。「全体のlatency」と「attemptごとのlatency」のどちらを測るのかを決めます。順序が重要なのに、あちこちで手作業で合成すると不整合が生まれます。composition rootで意味のある名前を付けた完成形を作ると追跡しやすくなります。
テストで委譲と失敗を確認する
Decoratorのテストでは、内側が適切な回数呼ばれること、引数とerrorが維持されること、追加責務が意図した範囲で働くことを確認します。
import assert from "node:assert/strict";
import test from "node:test";
test("retry delegates until the operation succeeds", async () => {
let calls = 0;
const unstable: SavePost = async () => {
calls += 1;
if (calls < 3) throw new TypeError("temporary network failure");
};
const decorated = withRetry(unstable, {
maxAttempts: 3,
isRetryable: (error) => error instanceof TypeError,
wait: async () => {},
});
await decorated({ id: "post-1", title: "test" });
assert.equal(calls, 3);
});
再試行してはいけないerrorがそのまま外へ出ることも重要です。
test("retry preserves a non-retryable error", async () => {
const original = new Error("validation failed");
let calls = 0;
const invalid: SavePost = async () => {
calls += 1;
throw original;
};
const decorated = withRetry(invalid, {
maxAttempts: 3,
isRetryable: (error) => error instanceof TypeError,
wait: async () => {},
});
await assert.rejects(
decorated({ id: "post-1", title: "" }),
(error) => error === original,
);
assert.equal(calls, 1);
});
logger testでは、error object全体のsnapshotよりevent名とIDを確認すると、内部形式の変更に強くなります。timeout testでは実時間を待つよりfake timerを使うと高速で安定します。
objectを包むDecorator
複数メソッドを持つinterfaceや、instanceとして依存注入したい場合はobject wrapperが適します。GoFの典型形に近い実装です。
interface Notifier {
send(message: string): Promise<void>;
}
class LoggingNotifier implements Notifier {
constructor(
private readonly inner: Notifier,
private readonly logger: Logger,
) {}
async send(message: string): Promise<void> {
this.logger.info("notify:start");
try {
await this.inner.send(message);
this.logger.info("notify:success");
} catch (error) {
this.logger.error("notify:failed", error);
throw error;
}
}
}
Decoratorは Notifier の全契約を保つ必要があります。interfaceへ新メソッドが追加されたとき、wrapper側の実装漏れはTypeScriptが検出できます。JavaScriptの Proxy で汎用的に横取りする方法もありますが、private field、this binding、property descriptor、デバッグ時の追跡に注意が必要です。明示的なwrapperの方が安全なことは少なくありません。
言語構文のdecoratorとは別物
ECMAScript decoratorsは、class、method、fieldなどの定義時に処理を適用する言語機能です。TypeScriptにも現行のdecorator対応と、過去の experimentalDecorators があります。次の構文を使ったからGoF Decoratorになる、使わないからパターンを実装できない、という関係ではありません。
function loggedMethod<This, Args extends unknown[], Return>(
original: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This>,
) {
return function (this: This, ...args: Args): Return {
console.log(`calling ${String(context.name)}`);
return original.call(this, ...args);
};
}
class Calculator {
@loggedMethod
add(a: number, b: number): number {
return a + b;
}
}
言語decoratorは定義へ処理を適用する仕組み、GoF Decoratorは同じinterfaceを持つobjectを包んで責務を合成する設計です。method decoratorが結果としてGoFに似たwrapperを作ることはありますが、評価時期、適用対象、交換方法が異なります。
RxJS operatorをDecorator的に読む
RxJS公式はpipeable operatorを「Observableを入力に取り、別のObservableを返す関数」と説明しています。元のObservable instanceは変更されません。operatorを pipe で重ね、変換、絞り込み、副作用などを宣言的に合成できます。
import { filter, map, of, tap } from "rxjs";
const source$ = of(1, 6, 10);
const result$ = source$.pipe(
map((value) => value * 2),
filter((value) => value > 10),
tap((value) => console.log(value)),
);
同じObservableという抽象を保ちながら振る舞いを重ねる点はDecorator的です。ただし、RxJS公式はpipeable operatorをGoF Decoratorとして分類していません。operatorにはlazyなsubscription、stream変換、error/completionといったRxJS固有の意味があります。本記事での対応付けは、合成可能なwrapperという考え方を学ぶ教育的な類推です。
NgRx meta-reducerをDecorator的に読む
NgRxのMetaReducerは、reducerを受け取りreducerを返します。公式資料でも、actionの記録、stateの前処理・後処理、元reducerへの委譲などに使えると説明されています。
import type { Action } from "@ngrx/store";
function loggerMetaReducer<State>(
reducer: (state: State | undefined, action: Action) => State,
) {
return (state: State | undefined, action: Action): State => {
console.log("before", state, action);
const nextState = reducer(state, action);
console.log("after", nextState);
return nextState;
};
}
元と同じreducer契約を返し、前後へ責務を加えるため、関数Decoratorに非常に近い構造です。それでも、NgRxの公式分類はMetaReducerであり、GoFパターンとしての宣言ではありません。またreducerは同期的で純粋な状態遷移という制約を持つため、非同期retryのようなDecoratorを同じ感覚で入れてはいけません。
より単純な代替案とトレードオフ
1つの処理だけへ1行のlogを足すなら、関数内へ直接書く方が理解しやすいでしょう。多数のHTTP handlerへ同じ関心事を適用するなら、frameworkのmiddlewareやinterceptorの方が適用範囲を管理しやすい場合があります。固定した振る舞いをclass階層全体で共有するなら継承も候補ですが、責務の組み合わせごとにsubclassが増えます。
Decoratorは組み合わせやすい反面、実際の実行順序が生成場所を見なければ分かりません。stack traceがwrapperで長くなり、関数名やmetadataが失われることもあります。同じDecoratorを二重適用するとlogや課金が重複します。適用済みを検出する仕組みを足すより、composition rootで一度だけ組み立てる方が単純なことも多いです。
失敗しやすい設計
- 元のerrorを別errorへ置き換え、原因やstackを失う。包むなら
causeを保持します。 - 引数や
AbortSignalを内側へ渡し忘れ、契約を壊す。 -
Promise.raceだけで処理をキャンセルできたと誤解する。 - retry可能性とidempotencyを確認せず、副作用を重複させる。
- 合成順序をテストせず、計測範囲やtimeout範囲を変えてしまう。
- 可変objectをDecorator間で共有し、同時実行の情報を混ぜる。
- arrow functionでmethodを包む際に必要な
thisを失う。 - 業務の中心処理まで多数のwrapperへ分散し、全体を追えなくする。
cache Decoratorではcache key、期限、errorをcacheするか、同時requestをまとめるかまで必要です。認可Decoratorでは「実行前に必ず拒否できるか」、transaction Decoratorでは「内側の全I/Oが同じtransactionを使うか」を確認します。責務名が簡単でも、保証する意味は具体的に定義してください。
使うかどうかの判断基準
Decoratorが向くのは、複数の対象へ同じ横断的責務を選択的に付けたい、元の契約を保てる、責務の組み合わせが複数ある、継承より実行時の合成が適している場合です。ログ、計測、再試行、timeout、cache、認可などが代表例ですが、対象処理の意味に合うことが前提です。
導入前に「元の入力・出力・error・キャンセルを保てるか」「適用順序は何を意味するか」「副作用を複数回実行してよいか」「どこで一度だけ組み立てるか」「middlewareなど既存の仕組みの方が適切ではないか」を確認します。wrapperが1つで再利用もないなら、直接実装する方が読みやすい可能性があります。
まとめ
Decoratorは、元と同じ契約を維持したwrapperを重ね、中心処理から横断的な責務を分離します。JavaScriptでは高階関数で小さく始め、複数methodを持つ場合はobject wrapperを使えます。価値は行数削減ではなく、責務ごとの変更とテストを局所化し、必要な組み合わせを明示できることです。
実装では合成順序、errorの保持、キャンセル、idempotencyが品質を左右します。また、RxJS operatorやNgRx MetaReducerに構造上の類似はありますが、公式のGoF分類ではありません。言語構文のdecoratorとも区別し、何を包み、どの契約を守るのかを基準に設計してください。
参考資料
- Erich Gammaほか『Design Patterns: Elements of Reusable Object-Oriented Software』Decorator章(Addison-Wesley, 1994)
- RxJS公式:Operators
- RxJS公式ソース:map operator
- NgRx公式:Meta-Reducers
- NgRx公式ソース:meta-reducers
- TC39 Decorators proposal
- TypeScript 5.0公式:Decorators
- MDN:AbortSignal.any