こんにちは。テクニカルリードの私だ。
今日のコードレビューで、こんなコードを見かけた。
interface UserAuth {
private token: string; // 怒りのコンパイルエラー:Interfaceに修飾子は使えない
validate(): boolean;
}
「あ、インターフェースに `private` を書こうとして挫折したな」とすぐに察しがついた。君も似たようなコードを書いたことがないか?あるいは、`type` エイリアスに逃げたり、適当なハッシュをつけてごまかしたりしていないだろうか。
TypeScriptの `interface` は、開発者の意思をコンパイラに伝えるための幻影だ。JavaScriptにコンパイルされた瞬間、文字通り跡形もなく消え去る。この「ランタイムの不在」こそが、TypeScriptの型安全性の限界であり、実務の現場で最もバグを生む温床となる。
今日は、この「Interfaceのプライベートプロパティ問題」の本質を暴き、ランタイムの壁を突破して「コンパイル時も実行時も絶対に破綻しない堅牢な設計パターン」を伝授しよう。
—
1. なぜInterfaceに「本当のプライベート」は存在しないのか
TypeScriptの型システムは 構造的型付け(Structural Subtyping) を採用している。つまり、「その形をしていれば、同じものとみなす」という哲学だ。
interface SecretHolder {
_secret: string;
}
const attacker: SecretHolder = {
_secret: “盗まれた機密情報”
};
ここに `private` キーワードを持ち込もうとしても、構造的型付けの世界では無力だ。もし `interface` がプライベートプロパティを持てたとしたら、TypeScriptのコンパイルモデルそのものが崩壊する。なぜなら、JSのランタイムには `private` という概念(ES2022の `#private` を除く)が存在せず、オブジェクトは常にオープンだからだ。
さらに致命的なのは、外部からAPI経由で取得したJSONデータ(`unknown` や `any`)は、ランタイムにおいて一切のインターフェースを保証しないという点だ。
interface UserProfile {
id: string;
privateData: string; // 本当は隠したい機密
}
// サーバーから返ってきた怪しいデータ
const response = JSON.parse(‘{“id”: “1”, “privateData”: “暴露されたパスワード”}’) as UserProfile;
console.log(response.privateData); // 普通にアクセスできてしまう
コンパイル時は「型安全だ」と安心しきっていても、ブラウザのDevToolsを開けば、隠したはずのデータは丸見えだ。これが、型エイリアスやインターフェースの限界である。
—
2. 回避策:TypeScriptの限界をランタイムで補完する設計パターン
では、どうすればよいのか?
答えはシンプルだ。「TypeScriptの型(Interface)」と「ランタイムのバリデーション(Guard)」を完全に同期させること。
ここでは、Zodなどのバリデーションライブラリを用いず、純粋なTypeScriptの型ガードと、ES2022のプライベートフィールド(`#`)を組み合わせた、プロダクションレベルの堅牢な設計パターンを提示する。
コピペで使えるプロダクションコード例
以下のコードは、APIレスポンスの安全性を担保しつつ、内部状態を完全にカプセル化(プライベート化)するクラスベースの設計だ。
/
- 1. データの「形状」を定義するインターフェース
- (ネットワーク境界を越えるデータの契約)
/
export interface SerializedUser {
readonly id: string;
readonly name: string;
// パスワードハッシュなど、絶対に外部に露出してはならない機密データ
readonly passwordHash: string;
}
/
- 2. ランタイムでの型ガード(Type Guard)
- コンパイル時の幻影を、実行時の現実にする
/
function isSerializedUser(value: unknown): value is SerializedUser {
return (
typeof value === “object” &&
value !== null &&
typeof (value as Record
typeof (value as Record
typeof (value as Record
);
}
/
- 3. カプセル化と振る舞いをカプセル化するドメインモデル
- JSのランタイムプライベート(#)を使用し、外から絶対にアクセスさせない
/
export class UserDomain {
// ── ランタイムレベルでのプライベートフィールド ──
#id: string;
#name: string;
#passwordHash: string;
private constructor(data: SerializedUser) {
this.#id = data.id;
this.#name = data.name;
this.#passwordHash = data.passwordHash;
}
/
- 外部の不確実なデータ(APIレスポンス等)から、安全にドメインモデルを生成するファクトリ
/
public static createFromResponse(rawResponse: unknown): UserDomain {
if (!isSerializedUser(rawResponse)) {
throw new TypeError(“不正なAPIレスポンスです:UserDomainの構築に失敗しました。”);
}
return new UserDomain(rawResponse);
}
// ── パブリックな振る舞い(Getter) ──
public get id(): string {
return this.#id;
}
public get name(): string {
return this.#name;
}
/
- 機密情報を検証するメソッド(データを持つオブジェクト自身がロジックを持つ)
/
public verifyPassword(hashToCompare: string): boolean {
// 実際のアプリではここで安全な比較処理を行う
return this.#passwordHash === hashToCompare;
}
}
// ==========================================
// 【使用例】実務でのフロー
// ==========================================
async function fetchUserData(apiUrl: string) {
const res = await fetch(apiUrl);
const json: unknown = await res.json();
try {
// ランタイムチェックを通過したものだけがDomainモデルになれる
const user = UserDomain.createFromResponse(json);
console.log(`ようこそ、 ${user.name} さん`);
// user.#passwordHash などにアクセスしようとすると、
// TypeScriptのコンパイルエラーになり、かつJSのランタイムでもアクセス不能。
} catch (error) {
console.error(“セキュリティ例外:”, error);
}
}
—
3. テクニカルリードからのアーキテクチャ解説
なぜこのアプローチが優れているのか、アーキテクチャの観点から3点に絞って解説する。
① インターフェースは「境界(Boundary)」にのみ使う
データ構造(DTO: Data Transfer Object)の定義には `interface` を使う。しかし、それをそのままビジネスロジックで触らせてはならない。API境界を跨いだ瞬間、`unknown` として受け取り、型ガード(`isSerializedUser`)を通すことで、「TypeScriptの型とランタイムの現実」を一致させる。
② TypeScriptの `private` ではなく、JSの `#private` を使え
TypeScriptの `private` キーワードは、あくまでコンパイル時の制限だ。ビルド後のJavaScriptを見れば、プロパティは露わになっている。
本当に機密性を保ちたい、あるいは意図しない書き込みから身を守りたい場合は、ES2022のプライベートフィールド(`#`)を使うべきだ。これにより、JSのランタイムレベルでカプセル化が強制される。
③ 貧なモデル(Anemic Domain Model)からの脱却
「データを持つだけのオブジェクト(Interface)」と「処理を書くだけの関数」を分離しすぎると、どこでバリデーションが行われたか分からなくなる。データと、それを守るロジック(今回の例では `#passwordHash` と `verifyPassword`)をひとつのクラスに閉じ込めることで、保守性が劇的に向上する。
—
結びにかえて
TypeScriptの `interface` は強力だが、過信してはならない。それはコンパイル時だけの「お題目」に過ぎないからだ。
「型があるから大丈夫」という慢心を捨て、「外部からの入力はすべて悪意あるデータ(あるいは汚染されたデータ)である」というゼロトラストの思想をコードに落とし込もう。
今日のレビューで、もしインターフェースのプライベート問題で悩んでいるメンバーがいたら、この記事をそっとシェアしてやってほしい。
君たちのコードベースが、一層堅牢になることを期待している。