Pythonで書いた関数デコレータをTypeScriptで再現する
こんばんは、情報システム室の夏目です。
普段はPythonで開発をしているのですが、最近Cloudflare Workersをいろいろ試している関係で、サーバーサイドでもTypeScriptを触るようになりました。
Pythonでの開発ではいくつかデコレータを自作して使っています。同じものがTypeScriptでもほしくなりました。
移植にあたっては、手元のPythonのコードをそのままClaude Code(モデルはFable5)に渡して、TypeScriptに書き換えてもらいました。
Pythonで書いたデコレータ
from collections.abc import Callable
from datetime import UTC, datetime
from functools import wraps
from uuid import uuid7
def logging_function(
write: bool = False, with_args: bool = False, with_return: bool = False
) -> Callable:
def decorator(func: Callable) -> Callable:
@wraps(func)
def inner_logging_function(*args, **kwargs):
dt_start = datetime.now(UTC)
call_id = str(uuid7())
try:
data = {
"message": f"start function `{func.__name__}` ({call_id})",
"function_name": func.__name__,
}
if with_args:
data["args"] = args
data["kwargs"] = kwargs
if write:
print(data)
result = func(*args, **kwargs)
dt_end = datetime.now(UTC)
delta = dt_end - dt_start
if with_return:
data["return"] = result
data["message"] = f"succeeded function `{func.__name__}` ({call_id})"
data["duration_secs"] = delta.total_seconds()
if write:
print(data)
return result
except Exception as e:
dt_end = datetime.now(UTC)
delta = dt_end - dt_start
print(
{
"message": f"failed function `{func.__name__}` ({call_id})",
"args": args,
"kwargs": kwargs,
"duration_secs": delta.total_seconds(),
"error": {"type": str(type(e)), "message": str(e)},
}
)
raise
return inner_logging_function
return decorator
対象の関数の開始と終了をログ出力するためのデコレータです。
(実際に使っているものはもう少し複雑なのですが、ここでは要点だけに絞っています)
なお uuid.uuid7 はPython 3.14で追加されたものです。それ以前のバージョンでは uuid4 などで代用してください。
このデコレータでは、例外が発生したときだけは write の設定に関わらず必ずログを出力するようにしています。障害調査の手がかりを取りこぼさないためです。
TypeScriptで再現した結果
type AnyFunction = (...args: never[]) => unknown;
export type LogRecord = Record<string, unknown>;
export type OptionsLoggingFunction = {
/** 開始・成功ログを出力するか。既定 false(失敗ログは設定に関わらず常に出力される) */
write?: boolean;
/** 開始・成功ログに引数を含めるか。既定 false */
withArgs?: boolean;
/** 成功ログに戻り値を含めるか。既定 false */
withReturn?: boolean;
/**
* 失敗ログに引数を含めるか。既定 true。
* パスワードやトークンなど秘匿情報を引数に取る関数では false にすること。
*/
withArgsOnFailure?: boolean;
/** ログの出力先。既定は console.log。レコードは出力ごとに新規オブジェクトで、後から書き換えられることはない */
logger?: (record: LogRecord) => void;
};
/** ラッパ関数から元の関数を参照するためのキー(Python の __wrapped__ 相当) */
export const wrapped: unique symbol = Symbol("loggingFunction.wrapped");
/** loggingFunction のラッパから元の関数を取り出す。ラッパでなければ undefined */
export function unwrap(fn: AnyFunction): AnyFunction | undefined {
return (fn as { [wrapped]?: AnyFunction })[wrapped];
}
// Date.now() はシステム時刻の変更や NTP 同期で巻き戻ることがあるため、単調時計が使える環境ではそちらを使う
const now: () => number =
typeof globalThis.performance?.now === "function" ? () => globalThis.performance.now() : Date.now;
function makeGenerateCallId(): () => string {
let callId: string | undefined;
function generateCallId(): string {
if (callId == null) {
if (typeof globalThis.crypto?.randomUUID === "function") {
callId = globalThis.crypto.randomUUID();
} else {
// randomUUID が使えない環境(非セキュアコンテキストのブラウザ等)向け。UUID 形式ではないが呼び出しの紐付けには足りる
callId = `${Date.now().toString(16)}-${Math.random().toString(36).slice(2, 12)}`;
}
}
return callId;
}
return generateCallId;
}
function isThenable(value: unknown): value is PromiseLike<unknown> {
return (
typeof value === "object" &&
value !== null &&
typeof (value as { then?: unknown }).then === "function"
);
}
function errorInfo(error: unknown): { type: string; message: string } {
if (error instanceof Error) {
return {
type: error.constructor.name,
message: error.message,
};
}
return {
type: typeof error,
message: String(error),
};
}
/**
* 関数の呼び出しを計測・記録するデコレータを生成する。
*
* 同期関数と、thenable を返す非同期関数の両方に対応する。
* ラッパは function 式で this を引き継ぐため、クラスやオブジェクトのメソッドにも適用できる。
*
* 制約:
* - クラスやコンストラクタ関数には適用できない。ラッパ経由の new は
* prototype が引き継がれないため instanceof が壊れる(クラスの場合は実行時エラーになる)
* - 非同期関数の戻り値は then() が返す Promise に置き換わり、元の Promise とは同一にならない
*/
export const loggingFunction = (options: OptionsLoggingFunction) => {
const {
write = false,
withArgs = false,
withArgsOnFailure = true,
withReturn = false,
logger = console.log,
} = options;
return <F extends AnyFunction>(func: F): F => {
const functionName = func.name || "<anonymous>";
// アロー関数ではなく function 式にすることで、呼び出し元の this をそのまま引き継ぐ
const innerLoggingFunction = function (
this: ThisParameterType<F>,
...args: Parameters<F>
): ReturnType<F> {
const start = now();
const getCallId = makeGenerateCallId();
if (write) {
const record: LogRecord = {
message: `start function \`${functionName}\` (${getCallId()})`,
function_name: functionName,
};
if (withArgs) {
record.args = args;
}
logger(record);
}
const onSuccess = (result: unknown): void => {
if (!write) {
return;
}
const delta = now() - start;
const record: LogRecord = {
message: `succeeded function \`${functionName}\` (${getCallId()})`,
function_name: functionName,
duration_secs: delta / 1000,
};
if (withReturn) {
record.return = result;
}
logger(record);
};
// 型注釈を変数側に置くことで、呼び出し後が到達不能であることが制御フロー解析に伝わる
const onFailure: (error: unknown) => never = (error) => {
const delta = now() - start;
const record: LogRecord = {
message: `failed function \`${functionName}\` (${getCallId()})`,
function_name: functionName,
duration_secs: delta / 1000,
error: errorInfo(error),
};
if (withArgsOnFailure) {
record.args = args;
}
logger(record);
throw error;
};
let result: unknown;
try {
result = func.apply(this, args as never[]);
} catch (e) {
onFailure(e);
}
if (isThenable(result)) {
return result.then((value) => {
onSuccess(value);
return value;
}, onFailure) as ReturnType<F>;
}
onSuccess(result);
return result as ReturnType<F>;
};
// name と length は writable: false のため defineProperty で設定する。
// name にはログと同じ解決済みの名前を使い、無名関数での食い違いをなくす
Object.defineProperty(innerLoggingFunction, "name", {
value: functionName,
configurable: true,
});
Object.defineProperty(innerLoggingFunction, "length", {
value: func.length,
configurable: true,
});
// 元の関数に生やされた静的プロパティ(キャッシュ等)を引き継ぐ
for (const key of Reflect.ownKeys(func)) {
if (key === "name" || key === "length" || key === "prototype") {
continue;
}
const descriptor = Object.getOwnPropertyDescriptor(func, key);
if (descriptor !== undefined) {
Object.defineProperty(innerLoggingFunction, key, descriptor);
}
}
Object.defineProperty(innerLoggingFunction, wrapped, {
value: func,
configurable: true,
});
return innerLoggingFunction as unknown as F;
};
};
Pythonにはあるのに、TypeScriptには標準で用意されていないものが多く、行数は大幅に増えてしまいました。
なお完全な移植ではなく、キーワード引数がないぶん引数まわりが簡素になっていたり、逆に出力先を差し替えるオプションが増えていたりと、細かい差異はあります。
使うときは、クラスのメソッドに @ を付けます。
type User = { id: string; name: string };
type Db = { findUser(id: string): Promise<User> };
class UserService {
constructor(private readonly db: Db) {}
@loggingFunction({ write: true })
async fetchUser(id: string): Promise<User> {
return this.db.findUser(id);
}
}
Pythonとほぼ同じ見た目で書けます。
メソッドの中で this.db がきちんと参照できるのは、後述するようにラッパーを function 式で書いているからです。アロー関数のままだと this が失われ、実行時に落ちます。
素の関数に使いたい場合
TypeScriptの @ はクラスとクラスのメンバーにしか付けられません。Pythonの感覚で function 宣言に付けると、コンパイルエラーになります。
// TS1206: Decorators are not valid here.
@loggingFunction({})
function fetchUser(id: string) {
return { id };
}
この場合は @ を諦めて、高階関数として直接呼び出します。
const fetchUser = loggingFunction({})(function fetchUser(id: string) {
return { id };
});
loggingFunction の実装はどちらの使い方でも共通です。デコレータといっても実体は「関数を受け取って関数を返す関数」なので、@ が使えるかどうかは書き方の違いでしかありません。
デコレータの基本
type AnyFunction = (...args: never[]) => unknown;
export type LogRecord = Record<string, unknown>;
export type OptionsLoggingFunction = {
write?: boolean;
withArgs?: boolean;
withReturn?: boolean;
withArgsOnFailure?: boolean;
logger?: (record: LogRecord) => void;
};
export const loggingFunction = (options: OptionsLoggingFunction) => {
return <F extends AnyFunction>(func: F): F => {
const innerLoggingFunction = function (
this: ThisParameterType<F>,
...args: Parameters<F>
): ReturnType<F> {
return func.apply(this, args as never[]) as ReturnType<F>;
};
return innerLoggingFunction as unknown as F;
};
};
オプションを受け取るデコレータは、だいたいこの形になります。
オプションを受け取る関数 → 対象の関数を受け取る関数 → 実際に処理をする関数 という三段の入れ子です。
type AnyFunction = (...args: never[]) => unknown;- 任意の関数型を表すための型定義です
- 引数の型は反変なので、
never[]にしておくとどんな引数を取る関数型でも型チェックを通過できます(この型のまま呼び出せるという意味ではありません)
return <F extends AnyFunction>(func: F): F => {}- デコレータを適用した関数が、ここの
funcに渡されます - 受け取った型パラメータ
Fをそのまま戻り値の型にしているので、ラップしても呼び出し側から見た引数と戻り値の型は変わりません- つまり、デコレータの存在を意識せずに関数を使えます
- デコレータを適用した関数が、ここの
const innerLoggingFunction = function (this: ThisParameterType<F>, ...args: Parameters<F>): ReturnType<F> {}- デコレータを適用した関数を呼び出したときに、実際に走る関数です
- この中で
func.apply(this, args as never[])を呼んでいるので、元の関数が実行され、その結果が返ります - アロー関数ではなく
function式で書くのがポイントです。 アロー関数は自分のthisを持たず定義時のスコープのthisを捕まえてしまうため、メソッドに適用するとthisが失われます。function式にしてapplyでthisを渡すことで、呼び出し元のthisがそのまま元の関数に届きます thisの型にはThisParameterType<F>を使い、元の関数が宣言しているthisの型をそのまま引き継いでいます- 最後の
as unknown as Fは、この内側の関数を「元の関数と同じ型」として扱わせるための型アサーションです。実際の引数と戻り値が一致していることはコンパイラではなく実装側で担保しています
オプションを受け取らない場合
type AnyFunction = (...args: never[]) => unknown;
export const loggingFunction = <F extends AnyFunction>(func: F): F => {
const innerLoggingFunction = function (
this: ThisParameterType<F>,
...args: Parameters<F>
): ReturnType<F> {
return func.apply(this, args as never[]) as ReturnType<F>;
};
return innerLoggingFunction as unknown as F;
};
オプションが不要なら、外側の一段を省いてこう書けます。オプションを受け取るときと違って、関数が一段少なくなっています。
使う側も括弧が一組減ります。
class UserService {
@loggingFunction
async fetchUser(id: string): Promise<User> {
return this.db.findUser(id);
}
}
素の関数に使う場合も同様です。
const fetchUser = loggingFunction(function fetchUser(id: string) {
return { id };
});
メタデータの引き継ぎ
// name と length は writable: false のため defineProperty で設定する。
// name にはログと同じ解決済みの名前を使い、無名関数での食い違いをなくす
Object.defineProperty(innerLoggingFunction, "name", {
value: functionName,
configurable: true,
});
Object.defineProperty(innerLoggingFunction, "length", {
value: func.length,
configurable: true,
});
// 元の関数に生やされた静的プロパティ(キャッシュ等)を引き継ぐ
for (const key of Reflect.ownKeys(func)) {
if (key === "name" || key === "length" || key === "prototype") {
continue;
}
const descriptor = Object.getOwnPropertyDescriptor(func, key);
if (descriptor !== undefined) {
Object.defineProperty(innerLoggingFunction, key, descriptor);
}
}
Object.defineProperty(innerLoggingFunction, wrapped, {
value: func,
configurable: true,
});
元の関数が持っていたメタデータや静的プロパティを、ラッパー側にコピーする処理です。
名前・引数の数・静的プロパティといった「外から見た姿」と、元の関数への参照が失われないようにしています。Pythonの functools.wraps に相当する部分です。
コピーしているのは名前と引数の数のほか、メモ化用のキャッシュオブジェクトのように後付けされたプロパティや、getter / setter などのプロパティディスクリプタです。
また wrapped シンボルを付けておくことで、unwrap() から元の関数を取り出せるようにしています。
Pythonの __wrapped__ 属性と同じ役割です。
なお prototype はあえてコピーしていません。そのためコンストラクタ関数に誤って適用してラッパー越しに new すると、instanceof が通らないオブジェクトができてしまいます。new.target を見て例外を投げるガードを入れることもできますが、そもそも適用しない前提のものなので今回はそこまではしていません。
非同期関数への対応
function isThenable(value: unknown): value is PromiseLike<unknown> {
return (
typeof value === "object" &&
value !== null &&
typeof (value as { then?: unknown }).then === "function"
);
}
if (isThenable(result)) {
return result.then((value) => {
onSuccess(value);
return value;
}, onFailure) as ReturnType<F>;
}
TypeScriptでは非同期関数を書く機会が多いので、そのための仕組みも用意しました。
instanceof Promise ではなく .then があるかどうかで判定しているため、ネイティブのPromise以外のthenableにも対応できます。
ただし戻り値は then() が返す新しいPromiseに置き換わるので、元のPromiseとは別のオブジェクトになります。
まとめ
以上、Pythonで使っていたデコレータをTypeScriptで再現した話でした。
何かのお役に立てれば幸いです。






