【実務・中級編】Interfaceのプライベートプロパティ問題:TypeScriptの型安全性の限界と回避策 – TypeScript コア・型システムの基礎解析バイブル

こんにちは。テクニカルリードの私だ。
今日のコードレビューで、こんなコードを見かけた。

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).id === “string” &&
typeof (value as Record).name === “string” &&
typeof (value as Record).passwordHash === “string”
);
}

/

  • 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` は強力だが、過信してはならない。それはコンパイル時だけの「お題目」に過ぎないからだ。

「型があるから大丈夫」という慢心を捨て、「外部からの入力はすべて悪意あるデータ(あるいは汚染されたデータ)である」というゼロトラストの思想をコードに落とし込もう。

今日のレビューで、もしインターフェースのプライベート問題で悩んでいるメンバーがいたら、この記事をそっとシェアしてやってほしい。
君たちのコードベースが、一層堅牢になることを期待している。

タイトルとURLをコピーしました