TypeScript Brand Types で実現する値の型安全性向上術

TypeScript で開発していて「userId
と productId
を間違えて渡してしまった」「文字列の ID が混在してバグが発生した」といった経験はありませんか?静的型付けの恩恵を受けているはずなのに、なぜこのような問題が起こってしまうのでしょうか。
実は、TypeScript の構造的型システムには、同じプリミティブ型同士を区別できないという根本的な課題があります。この記事では、Brand Types という強力な手法を使って、値レベルでの型安全性を劇的に向上させる方法を詳しく解説します。実際のプロジェクトで即座に活用できる実践的なパターンとともに、あなたのコードをより堅牢で保守性の高いものに変えていきましょう。
背景:プリミティブ型の落とし穴と実際の開発現場での混乱例
TypeScript の型システムは確かに強力ですが、実際の開発現場では予想外の型安全性の穴に悩まされることがあります。特に、プリミティブ型を扱う際の課題は深刻で、多くの開発チームが経験する問題でもあります。
よくある型混同の事例
実際のプロジェクトでよく発生する問題を見てみましょう。
typescript// 一見正しく見える関数定義
function transferMoney(
fromUserId: string,
toUserId: string,
amount: number
) {
// 送金処理の実装
console.log(
`Transfer ${amount} from ${fromUserId} to ${toUserId}`
);
}
function getProduct(productId: string): Product {
// 商品取得の実装
return { id: productId, name: 'Sample Product' };
}
// 使用例:一見正しく見えるが...
const currentUser = 'user_123';
const targetProduct = 'product_456';
// ❌ 引数の順序を間違えても TypeScript は警告してくれない
transferMoney(targetProduct, currentUser, 1000);
// ^^^^^^^^^^^^ ^^^^^^^^^^^
// productId が userId として
// userId として 渡されている
// 渡されている
// ❌ 異なる種類のIDを混同してもエラーにならない
const product = getProduct(currentUser);
// ^^^^^^^^^^^
// userIdがproductIdとして渡されている
この例では、すべて string
型として定義されているため、TypeScript コンパイラーは型チェックを通してしまいます。しかし、実際の業務ロジックでは明らかに異なる意味を持つ値です。
実際の開発現場で発生する深刻な問題
問題 1:API レスポンスでの ID 混同
typescriptinterface User {
id: string;
name: string;
departmentId: string;
}
interface Department {
id: string;
name: string;
managerId: string;
}
// APIから取得したデータの処理
async function assignUserToDepartment(
user: User,
department: Department
) {
// ❌ 間違い:department.id を渡すべきなのに user.id を渡している
const result = await updateUserDepartment(
user.id,
user.id
);
// ^^^^^^^
// 本来は department.id
return result;
}
問題 2:データベース操作での型ミスマッチ
typescript// データベース操作の例
class UserRepository {
async findById(id: string): Promise<User | null> {
return await db.query(
'SELECT * FROM users WHERE id = ?',
[id]
);
}
async findByDepartmentId(
departmentId: string
): Promise<User[]> {
return await db.query(
'SELECT * FROM users WHERE department_id = ?',
[departmentId]
);
}
}
// 使用例
const userRepo = new UserRepository();
const departmentId = 'dept_123';
const userId = 'user_456';
// ❌ 意図せずに userId を departmentId として使用
const users = await userRepo.findByDepartmentId(userId);
// ^^^^^^
// 間違った ID 型
Brand Types がもたらす型安全性の革命
Brand Types は、これらの問題を根本的に解決する強力な手法です。同じプリミティブ型でも、論理的に異なる意味を持つ値を型レベルで区別できるようになります。
Brand Types 導入後の改善例
typescript// Brand Types を使った改善例
type UserId = string & { readonly brand: unique symbol };
type ProductId = string & { readonly brand: unique symbol };
type DepartmentId = string & {
readonly brand: unique symbol;
};
// 型安全な関数定義
function transferMoney(
fromUserId: UserId,
toUserId: UserId,
amount: number
) {
console.log(
`Transfer ${amount} from ${fromUserId} to ${toUserId}`
);
}
function getProduct(productId: ProductId): Product {
return { id: productId, name: 'Sample Product' };
}
// 使用例
const currentUser = 'user_123' as UserId;
const targetProduct = 'product_456' as ProductId;
// ✅ TypeScript が型エラーを検出してくれる
transferMoney(targetProduct, currentUser, 1000);
// ^^^^^^^^^^^^^
// Type 'ProductId' is not assignable to type 'UserId'
const product = getProduct(currentUser);
// ^^^^^^^^^^^
// Type 'UserId' is not assignable to type 'ProductId'
このように、Brand Types を導入することで、コンパイル時に型の混同を検出し、バグを未然に防ぐことができます。
Brand Types の基本概念と設計思想
Brand Types を効果的に活用するためには、まずその理論的背景と TypeScript における実装方法を理解することが重要です。
Nominal Typing vs Structural Typing の理解
TypeScript の型システムを深く理解するために、異なる型システムのアプローチを比較してみましょう。
Structural Typing(構造的型付け)
TypeScript は構造的型付けを採用しています。これは、型の互換性を構造(プロパティとその型)によって判定する方式です。
typescript// 構造的型付けの例
interface Point2D {
x: number;
y: number;
}
interface Vector2D {
x: number;
y: number;
}
// 構造が同じため、型として互換性がある
const point: Point2D = { x: 1, y: 2 };
const vector: Vector2D = point; // ✅ エラーにならない
function calculateDistance(
from: Point2D,
to: Point2D
): number {
return Math.sqrt(
Math.pow(to.x - from.x, 2) + Math.pow(to.y - from.y, 2)
);
}
// Vector2D を Point2D として使用できてしまう
const distance = calculateDistance(vector, point); // ✅ エラーにならない
Nominal Typing(名目的型付け)
一方、名目的型付けでは、型の名前によって互換性を判定します。Java や C# などで採用されている方式です。
typescript// 名目的型付けのイメージ(TypeScript では標準サポートされていない)
class Point2D {
constructor(public x: number, public y: number) {}
}
class Vector2D {
constructor(public x: number, public y: number) {}
}
// 名目的型付けなら、構造が同じでも異なる型として扱われる
const point = new Point2D(1, 2);
const vector = new Vector2D(3, 4);
// 名目的型付けなら型エラーになる(実際の TypeScript では class なのでエラーにならない)
function calculateDistance(
from: Point2D,
to: Point2D
): number {
// 実装
}
Brand Types の実装パターン
TypeScript で名目的型付けのような効果を得るために、Brand Types という手法が開発されました。主要な実装パターンを見ていきましょう。
パターン 1:Symbol Brand
typescript// Symbol を使った Brand Types の実装
type UserId = string & { readonly __brand: 'UserId' };
type ProductId = string & { readonly __brand: 'ProductId' };
// ファクトリー関数による安全な生成
function createUserId(value: string): UserId {
// バリデーションロジックをここに含めることができる
if (!value || !value.startsWith('user_')) {
throw new Error('Invalid user ID format');
}
return value as UserId;
}
function createProductId(value: string): ProductId {
if (!value || !value.startsWith('product_')) {
throw new Error('Invalid product ID format');
}
return value as ProductId;
}
// 使用例
const userId = createUserId('user_123');
const productId = createProductId('product_456');
// 型安全性が確保される
function getUser(id: UserId): User {
// 実装
}
getUser(productId); // ❌ Type 'ProductId' is not assignable to type 'UserId'
パターン 2:Unique Symbol Brand
より厳密な型区別を実現するために、unique symbol
を使用する方法もあります。
typescript// Unique Symbol を使った更に厳密な実装
declare const USER_ID_BRAND: unique symbol;
declare const PRODUCT_ID_BRAND: unique symbol;
declare const DEPARTMENT_ID_BRAND: unique symbol;
type UserId = string & { readonly [USER_ID_BRAND]: never };
type ProductId = string & {
readonly [PRODUCT_ID_BRAND]: never;
};
type DepartmentId = string & {
readonly [DEPARTMENT_ID_BRAND]: never;
};
// 型ガード関数の実装
function isUserId(value: string): value is UserId {
return value.startsWith('user_') && value.length > 5;
}
function isProductId(value: string): value is ProductId {
return value.startsWith('product_') && value.length > 8;
}
// 使用例
function processId(value: string) {
if (isUserId(value)) {
// この中では value は UserId 型として扱われる
return getUserData(value);
}
if (isProductId(value)) {
// この中では value は ProductId 型として扱われる
return getProductData(value);
}
throw new Error('Unknown ID type');
}
TypeScript コンパイラーとの相互作用
Brand Types は TypeScript の型システムを巧妙に活用した手法です。コンパイラーがどのように Brand Types を処理するかを理解することで、より効果的な活用が可能になります。
コンパイル時の型チェック
typescript// コンパイル時の型チェックの動作
type Temperature = number & { readonly __unit: 'Celsius' };
type Distance = number & { readonly __unit: 'Meters' };
function celsiusToFahrenheit(temp: Temperature): number {
return (temp * 9) / 5 + 32;
}
function calculateSpeed(
distance: Distance,
time: number
): number {
return distance / time;
}
// 実行時には通常の number として扱われる
const temp = 25 as Temperature;
const dist = 100 as Distance;
console.log(celsiusToFahrenheit(temp)); // ✅ 正常動作
console.log(calculateSpeed(dist, 10)); // ✅ 正常動作
// ❌ 型エラー:異なる Brand 型同士は互換性がない
console.log(celsiusToFahrenheit(dist));
console.log(calculateSpeed(temp, 10));
ランタイムでの動作
typescript// Brand Types はコンパイル後に消去される
type UserId = string & { readonly brand: unique symbol };
function logUserId(id: UserId) {
console.log('User ID:', id);
console.log('Type:', typeof id); // "string" と出力される
}
const userId = 'user_123' as UserId;
logUserId(userId);
// コンパイル後の JavaScript では通常の string として扱われる
// ランタイムオーバーヘッドは一切発生しない
実用的な Brand Types パターン集
実際のプロジェクトで即座に活用できる、具体的な Brand Types のパターンを紹介します。これらのパターンは、多くの開発現場で共通して発生する問題を解決するものです。
ID 型の安全な管理
アプリケーションで最も重要かつ頻繁に使用されるのが、各種 ID の管理です。Brand Types を活用することで、ID の混同によるバグを効果的に防げます。
基本的な ID 型の定義
typescript// 基本的な ID Brand Types の定義
declare const USER_ID: unique symbol;
declare const PRODUCT_ID: unique symbol;
declare const ORDER_ID: unique symbol;
declare const CATEGORY_ID: unique symbol;
export type UserId = string & { readonly [USER_ID]: never };
export type ProductId = string & {
readonly [PRODUCT_ID]: never;
};
export type OrderId = string & {
readonly [ORDER_ID]: never;
};
export type CategoryId = string & {
readonly [CATEGORY_ID]: never;
};
// ID 生成のためのユーティリティ関数
export const IdUtils = {
user: (value: string): UserId => {
if (!value.match(/^user_[a-zA-Z0-9]+$/)) {
throw new Error(`Invalid user ID format: ${value}`);
}
return value as UserId;
},
product: (value: string): ProductId => {
if (!value.match(/^prod_[a-zA-Z0-9]+$/)) {
throw new Error(
`Invalid product ID format: ${value}`
);
}
return value as ProductId;
},
order: (value: string): OrderId => {
if (!value.match(/^order_[a-zA-Z0-9]+$/)) {
throw new Error(`Invalid order ID format: ${value}`);
}
return value as OrderId;
},
category: (value: string): CategoryId => {
if (!value.match(/^cat_[a-zA-Z0-9]+$/)) {
throw new Error(
`Invalid category ID format: ${value}`
);
}
return value as CategoryId;
},
} as const;
実際のビジネスロジックでの活用
typescript// ドメインモデルでの Brand Types 活用
interface User {
readonly id: UserId;
readonly name: string;
readonly email: string;
readonly departmentId?: DepartmentId;
}
interface Product {
readonly id: ProductId;
readonly name: string;
readonly price: number;
readonly categoryId: CategoryId;
readonly sellerId: UserId;
}
interface Order {
readonly id: OrderId;
readonly customerId: UserId;
readonly productId: ProductId;
readonly quantity: number;
readonly totalAmount: number;
}
// 型安全なサービス層の実装
class OrderService {
async createOrder(
customerId: UserId,
productId: ProductId,
quantity: number
): Promise<Order> {
// 型安全性が保証された状態でビジネスロジックを実装
const product = await this.productService.findById(
productId
);
const customer = await this.userService.findById(
customerId
);
if (!product || !customer) {
throw new Error('Product or customer not found');
}
const orderId = IdUtils.order(
`order_${Date.now()}_${Math.random()}`
);
return {
id: orderId,
customerId,
productId,
quantity,
totalAmount: product.price * quantity,
};
}
// ❌ 以下のような間違いはコンパイル時に検出される
async getOrdersByProduct(
productId: UserId
): Promise<Order[]> {
// ^^^^^^
// Type 'UserId' is not assignable to type 'ProductId'
return [];
}
}
文字列検証型
メールアドレス、URL、電話番号など、特定の形式を持つ文字列に対しても Brand Types は威力を発揮します。
Email 型の実装
typescriptdeclare const EMAIL_BRAND: unique symbol;
export type Email = string & {
readonly [EMAIL_BRAND]: never;
};
// Email バリデーション関数
export function createEmail(value: string): Email {
const emailRegex =
/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
if (!emailRegex.test(value)) {
throw new Error(`Invalid email format: ${value}`);
}
return value as Email;
}
// 型ガード関数
export function isEmail(value: string): value is Email {
const emailRegex =
/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
return emailRegex.test(value);
}
// 使用例
class UserRegistrationService {
async registerUser(
name: string,
emailInput: string,
password: string
) {
// Email 型に変換(バリデーション込み)
const email = createEmail(emailInput);
// 以降、email は Email 型として扱われ、型安全性が保証される
return await this.createUser(name, email, password);
}
private async createUser(
name: string,
email: Email,
password: string
): Promise<User> {
// Email 型が保証されているため、追加のバリデーションは不要
return {
id: IdUtils.user(`user_${Date.now()}`),
name,
email, // Email 型として安全に使用
};
}
}
URL 型と PhoneNumber 型の実装
typescriptdeclare const URL_BRAND: unique symbol;
declare const PHONE_BRAND: unique symbol;
export type ValidUrl = string & {
readonly [URL_BRAND]: never;
};
export type PhoneNumber = string & {
readonly [PHONE_BRAND]: never;
};
// URL バリデーション
export function createValidUrl(value: string): ValidUrl {
try {
new URL(value);
return value as ValidUrl;
} catch {
throw new Error(`Invalid URL format: ${value}`);
}
}
// 電話番号バリデーション(日本の形式例)
export function createPhoneNumber(
value: string
): PhoneNumber {
const phoneRegex =
/^(\+81[-\s]?|0)[0-9]{1,4}[-\s]?[0-9]{1,4}[-\s]?[0-9]{4}$/;
if (!phoneRegex.test(value)) {
throw new Error(
`Invalid phone number format: ${value}`
);
}
return value as PhoneNumber;
}
// 活用例
interface ContactInfo {
email: Email;
website?: ValidUrl;
phone?: PhoneNumber;
}
class ContactService {
createContact(
emailInput: string,
websiteInput?: string,
phoneInput?: string
): ContactInfo {
const contact: ContactInfo = {
email: createEmail(emailInput),
};
if (websiteInput) {
contact.website = createValidUrl(websiteInput);
}
if (phoneInput) {
contact.phone = createPhoneNumber(phoneInput);
}
return contact;
}
}
数値制約型
数値に対しても、ビジネスルールに基づいた制約を型レベルで表現できます。
正の数値型の実装
typescriptdeclare const POSITIVE_NUMBER_BRAND: unique symbol;
declare const PERCENTAGE_BRAND: unique symbol;
declare const CURRENCY_BRAND: unique symbol;
export type PositiveNumber = number & {
readonly [POSITIVE_NUMBER_BRAND]: never;
};
export type Percentage = number & {
readonly [PERCENTAGE_BRAND]: never;
};
export type Currency = number & {
readonly [CURRENCY_BRAND]: never;
};
// 正の数値の生成
export function createPositiveNumber(
value: number
): PositiveNumber {
if (value <= 0 || !Number.isFinite(value)) {
throw new Error(`Value must be positive: ${value}`);
}
return value as PositiveNumber;
}
// パーセンテージの生成(0-100の範囲)
export function createPercentage(
value: number
): Percentage {
if (value < 0 || value > 100 || !Number.isFinite(value)) {
throw new Error(
`Percentage must be between 0 and 100: ${value}`
);
}
return value as Percentage;
}
// 通貨の生成(小数点以下2桁まで)
export function createCurrency(value: number): Currency {
if (!Number.isFinite(value) || value < 0) {
throw new Error(`Invalid currency value: ${value}`);
}
// 小数点以下2桁に丸める
const rounded = Math.round(value * 100) / 100;
return rounded as Currency;
}
数値型のビジネスロジック活用
typescript// 商品価格計算の例
interface Product {
readonly id: ProductId;
readonly name: string;
readonly basePrice: Currency;
readonly discountRate: Percentage;
readonly stockQuantity: PositiveNumber;
}
class PricingService {
calculateDiscountedPrice(product: Product): Currency {
const discountAmount =
(product.basePrice * product.discountRate) / 100;
return createCurrency(
product.basePrice - discountAmount
);
}
calculateTotalPrice(
product: Product,
quantity: PositiveNumber
): Currency {
const unitPrice =
this.calculateDiscountedPrice(product);
return createCurrency(unitPrice * quantity);
}
// ❌ 負の数量は型レベルで防止される
invalidOrder(product: Product): Currency {
const invalidQuantity = -5; // number 型
return this.calculateTotalPrice(
product,
invalidQuantity
);
// ^^^^^^^^^^^^^
// Type 'number' is not assignable to type 'PositiveNumber'
}
}
日時型の厳密な管理
日時の管理も Brand Types で大幅に改善できます。タイムゾーンや形式の混同を防げます。
日時 Brand Types の実装
typescriptdeclare const UTC_TIMESTAMP_BRAND: unique symbol;
declare const LOCAL_DATE_BRAND: unique symbol;
declare const ISO_DATE_STRING_BRAND: unique symbol;
export type UtcTimestamp = number & {
readonly [UTC_TIMESTAMP_BRAND]: never;
};
export type LocalDate = string & {
readonly [LOCAL_DATE_BRAND]: never;
};
export type IsoDateString = string & {
readonly [ISO_DATE_STRING_BRAND]: never;
};
// UTC タイムスタンプの生成
export function createUtcTimestamp(
date?: Date
): UtcTimestamp {
const timestamp = date ? date.getTime() : Date.now();
return timestamp as UtcTimestamp;
}
// ローカル日付文字列の生成(YYYY-MM-DD形式)
export function createLocalDate(date: Date): LocalDate {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(
2,
'0'
);
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}` as LocalDate;
}
// ISO文字列の生成
export function createIsoDateString(
date: Date
): IsoDateString {
return date.toISOString() as IsoDateString;
}
// 文字列からの変換
export function parseLocalDate(
dateString: string
): LocalDate {
const regex = /^\d{4}-\d{2}-\d{2}$/;
if (!regex.test(dateString)) {
throw new Error(`Invalid date format: ${dateString}`);
}
const date = new Date(dateString);
if (isNaN(date.getTime())) {
throw new Error(`Invalid date: ${dateString}`);
}
return dateString as LocalDate;
}
日時型の活用パターン
typescript// イベント管理システムでの活用例
interface Event {
readonly id: EventId;
readonly title: string;
readonly startTime: UtcTimestamp;
readonly endTime: UtcTimestamp;
readonly eventDate: LocalDate;
readonly lastModified: IsoDateString;
}
class EventService {
createEvent(
title: string,
startDate: Date,
endDate: Date
): Event {
const eventDate = createLocalDate(startDate);
const startTime = createUtcTimestamp(startDate);
const endTime = createUtcTimestamp(endDate);
const lastModified = createIsoDateString(new Date());
return {
id: IdUtils.event(`event_${Date.now()}`),
title,
startTime,
endTime,
eventDate,
lastModified,
};
}
// 日付でのフィルタリング
async getEventsByDate(date: LocalDate): Promise<Event[]> {
// LocalDate 型が保証されているため、安全にクエリを実行
return await this.eventRepository.findByDate(date);
}
// 期間でのフィルタリング
async getEventsInPeriod(
start: UtcTimestamp,
end: UtcTimestamp
): Promise<Event[]> {
if (start >= end) {
throw new Error('Start time must be before end time');
}
return await this.eventRepository.findInTimeRange(
start,
end
);
}
}
ビジネスロジックでの活用戦略
Brand Types の真の価値は、実際のビジネスロジックで活用した時に発揮されます。ドメイン駆動設計や API 設計、データベース操作など、様々な場面での具体的な活用戦略を見ていきましょう。
ドメイン駆動設計との組み合わせ
ドメイン駆動設計(DDD)では、ドメインモデルの表現力と整合性が重要です。Brand Types は、この要求を満たす強力な手段となります。
Value Object としての Brand Types
typescript// 金額を表現する Value Object
declare const MONEY_BRAND: unique symbol;
export type Money = number & {
readonly [MONEY_BRAND]: never;
};
export class MoneyValueObject {
private constructor(private readonly _value: Money) {}
static create(
amount: number,
currency: 'JPY' | 'USD' | 'EUR' = 'JPY'
): MoneyValueObject {
if (amount < 0) {
throw new Error('Amount cannot be negative');
}
// 通貨に応じた小数点処理
const normalizedAmount =
currency === 'JPY'
? Math.round(amount)
: Math.round(amount * 100) / 100;
return new MoneyValueObject(normalizedAmount as Money);
}
get value(): Money {
return this._value;
}
add(other: MoneyValueObject): MoneyValueObject {
return MoneyValueObject.create(
this._value + other._value
);
}
multiply(factor: number): MoneyValueObject {
return MoneyValueObject.create(this._value * factor);
}
equals(other: MoneyValueObject): boolean {
return this._value === other._value;
}
}
// 商品エンティティでの活用
class Product {
constructor(
private readonly _id: ProductId,
private readonly _name: string,
private readonly _price: MoneyValueObject
) {}
get id(): ProductId {
return this._id;
}
get name(): string {
return this._name;
}
get price(): MoneyValueObject {
return this._price;
}
// 価格変更のビジネスルール
changePrice(newPrice: MoneyValueObject): Product {
// ビジネスルール:価格は現在価格の50%以上、200%以下でなければならない
const currentAmount = this._price.value;
const newAmount = newPrice.value;
if (
newAmount < currentAmount * 0.5 ||
newAmount > currentAmount * 2.0
) {
throw new Error('Price change exceeds allowed range');
}
return new Product(this._id, this._name, newPrice);
}
}
ドメインサービスでの型安全性
typescript// 注文ドメインサービス
class OrderDomainService {
constructor(
private readonly userRepository: UserRepository,
private readonly productRepository: ProductRepository
) {}
async createOrder(
customerId: UserId,
orderItems: Array<{
productId: ProductId;
quantity: PositiveNumber;
}>
): Promise<Order> {
// 顧客の存在確認
const customer = await this.userRepository.findById(
customerId
);
if (!customer) {
throw new Error(`Customer not found: ${customerId}`);
}
// 商品の存在確認と在庫チェック
const products = new Map<ProductId, Product>();
for (const item of orderItems) {
const product = await this.productRepository.findById(
item.productId
);
if (!product) {
throw new Error(
`Product not found: ${item.productId}`
);
}
if (product.stockQuantity < item.quantity) {
throw new Error(
`Insufficient stock for product: ${item.productId}`
);
}
products.set(item.productId, product);
}
// 注文総額の計算
let totalAmount = MoneyValueObject.create(0);
const orderLines: OrderLine[] = [];
for (const item of orderItems) {
const product = products.get(item.productId)!;
const lineTotal = product.price.multiply(
item.quantity
);
totalAmount = totalAmount.add(lineTotal);
orderLines.push(
new OrderLine(
item.productId,
product.name,
product.price,
item.quantity,
lineTotal
)
);
}
return new Order(
IdUtils.order(`order_${Date.now()}_${Math.random()}`),
customerId,
orderLines,
totalAmount,
createUtcTimestamp()
);
}
}
API レスポンスの型安全性確保
外部 API との通信においても、Brand Types は型安全性を大幅に向上させます。
API レスポンスの型定義
typescript// API レスポンス用の Brand Types
declare const API_USER_ID_BRAND: unique symbol;
declare const API_TIMESTAMP_BRAND: unique symbol;
export type ApiUserId = string & {
readonly [API_USER_ID_BRAND]: never;
};
export type ApiTimestamp = string & {
readonly [API_TIMESTAMP_BRAND]: never;
};
// API レスポンスの型定義
interface ApiUserResponse {
id: ApiUserId;
name: string;
email: Email;
created_at: ApiTimestamp;
updated_at: ApiTimestamp;
}
interface ApiProductResponse {
id: ProductId;
name: string;
price: number;
seller_id: ApiUserId;
created_at: ApiTimestamp;
}
// API クライアントの実装
class ApiClient {
async getUser(id: UserId): Promise<ApiUserResponse> {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
// API レスポンスのバリデーションと変換
return this.validateUserResponse(data);
}
private validateUserResponse(data: any): ApiUserResponse {
if (!data.id || typeof data.id !== 'string') {
throw new Error('Invalid user ID in API response');
}
if (!data.email || !isEmail(data.email)) {
throw new Error('Invalid email in API response');
}
return {
id: data.id as ApiUserId,
name: data.name,
email: createEmail(data.email),
created_at: data.created_at as ApiTimestamp,
updated_at: data.updated_at as ApiTimestamp,
};
}
}
// ドメインモデルへの変換
class UserMapper {
static fromApiResponse(apiUser: ApiUserResponse): User {
return {
id: this.convertApiUserIdToUserId(apiUser.id),
name: apiUser.name,
email: apiUser.email,
createdAt: this.parseApiTimestamp(apiUser.created_at),
};
}
private static convertApiUserIdToUserId(
apiId: ApiUserId
): UserId {
// API の ID 形式からドメインの ID 形式への変換ロジック
return IdUtils.user(`user_${apiId}`);
}
private static parseApiTimestamp(
timestamp: ApiTimestamp
): UtcTimestamp {
const date = new Date(timestamp);
if (isNaN(date.getTime())) {
throw new Error(`Invalid timestamp: ${timestamp}`);
}
return createUtcTimestamp(date);
}
}
データベース操作での型安全性
データベース操作においても、Brand Types によって SQL インジェクションの防止や型ミスマッチの検出が可能になります。
Repository パターンでの活用
typescript// データベースクエリでの Brand Types 活用
interface UserRecord {
id: string;
name: string;
email: string;
department_id: string | null;
created_at: Date;
}
class UserRepository {
constructor(private readonly db: Database) {}
async findById(id: UserId): Promise<User | null> {
const query = 'SELECT * FROM users WHERE id = ?';
const record = await this.db.queryOne<UserRecord>(
query,
[id]
);
if (!record) {
return null;
}
return this.mapRecordToUser(record);
}
async findByDepartment(
departmentId: DepartmentId
): Promise<User[]> {
const query =
'SELECT * FROM users WHERE department_id = ?';
const records = await this.db.queryMany<UserRecord>(
query,
[departmentId]
);
return records.map((record) =>
this.mapRecordToUser(record)
);
}
async create(user: CreateUserRequest): Promise<User> {
const userId = IdUtils.user(
`user_${Date.now()}_${Math.random()}`
);
const query = `
INSERT INTO users (id, name, email, department_id, created_at)
VALUES (?, ?, ?, ?, ?)
`;
await this.db.execute(query, [
userId,
user.name,
user.email,
user.departmentId || null,
new Date(),
]);
return this.findById(userId) as Promise<User>;
}
private mapRecordToUser(record: UserRecord): User {
return {
id: IdUtils.user(record.id),
name: record.name,
email: createEmail(record.email),
departmentId: record.department_id
? IdUtils.department(record.department_id)
: undefined,
createdAt: createUtcTimestamp(record.created_at),
};
}
}
フォームバリデーションとの統合
フロントエンドでのフォームバリデーションにおいても、Brand Types は強力な型安全性を提供します。
React フォームでの活用例
typescript// フォーム用の Brand Types
declare const FORM_EMAIL_BRAND: unique symbol;
declare const FORM_PASSWORD_BRAND: unique symbol;
export type FormEmail = string & {
readonly [FORM_EMAIL_BRAND]: never;
};
export type FormPassword = string & {
readonly [FORM_PASSWORD_BRAND]: never;
};
// バリデーション関数
export function validateFormEmail(
input: string
): FormEmail {
const email = createEmail(input); // Email Brand Type を再利用
return input as FormEmail; // フォーム用の Brand Type として返す
}
export function validateFormPassword(
input: string
): FormPassword {
if (input.length < 8) {
throw new Error(
'Password must be at least 8 characters'
);
}
if (!/[A-Z]/.test(input)) {
throw new Error(
'Password must contain at least one uppercase letter'
);
}
if (!/[0-9]/.test(input)) {
throw new Error(
'Password must contain at least one number'
);
}
return input as FormPassword;
}
// React コンポーネントでの活用
interface UserRegistrationFormData {
email: FormEmail;
password: FormPassword;
confirmPassword: string;
}
const UserRegistrationForm: React.FC = () => {
const [formData, setFormData] = useState<
Partial<UserRegistrationFormData>
>({});
const [errors, setErrors] = useState<
Record<string, string>
>({});
const handleEmailChange = (input: string) => {
try {
const validEmail = validateFormEmail(input);
setFormData((prev) => ({
...prev,
email: validEmail,
}));
setErrors((prev) => ({ ...prev, email: '' }));
} catch (error) {
setErrors((prev) => ({
...prev,
email:
error instanceof Error
? error.message
: 'Invalid email',
}));
}
};
const handlePasswordChange = (input: string) => {
try {
const validPassword = validateFormPassword(input);
setFormData((prev) => ({
...prev,
password: validPassword,
}));
setErrors((prev) => ({ ...prev, password: '' }));
} catch (error) {
setErrors((prev) => ({
...prev,
password:
error instanceof Error
? error.message
: 'Invalid password',
}));
}
};
const handleSubmit = async () => {
if (!formData.email || !formData.password) {
return; // 型レベルで不完全なデータの送信を防止
}
// Brand Types により型安全性が保証された状態で API 呼び出し
await registerUser({
email: formData.email,
password: formData.password,
});
};
return (
<form onSubmit={handleSubmit}>
{/* フォーム実装 */}
</form>
);
};
パフォーマンスと開発体験の最適化
Brand Types を効果的に活用するためには、パフォーマンスへの影響を最小限に抑えつつ、開発体験を向上させる工夫が必要です。
ランタイムオーバーヘッドの回避
Brand Types の最大の利点の一つは、ランタイムでのオーバーヘッドが一切発生しないことです。この特性を最大限活用する方法を説明します。
ゼロコスト抽象化の実現
typescript// ランタイムでのパフォーマンス測定例
function performanceTest() {
const iterations = 1000000;
// 通常の string での処理
console.time('Plain string processing');
for (let i = 0; i < iterations; i++) {
const id = `user_${i}`;
processPlainString(id);
}
console.timeEnd('Plain string processing');
// Brand Types での処理
console.time('Brand Types processing');
for (let i = 0; i < iterations; i++) {
const id = `user_${i}` as UserId;
processBrandedString(id);
}
console.timeEnd('Brand Types processing');
// 結果:処理時間はほぼ同等(型チェックはコンパイル時のみ)
}
function processPlainString(id: string): void {
// 処理ロジック
}
function processBrandedString(id: UserId): void {
// 同じ処理ロジック(型安全性が追加されただけ)
}
メモリ使用量の最適化
typescript// Brand Types はメモリオーバーヘッドを発生させない
interface UserData {
id: UserId;
email: Email;
createdAt: UtcTimestamp;
}
// コンパイル後は通常の JavaScript オブジェクトと同等
const userData: UserData = {
id: 'user_123' as UserId,
email: 'user@example.com' as Email,
createdAt: 1640995200000 as UtcTimestamp,
};
// JSON シリアライゼーションも通常通り
const serialized = JSON.stringify(userData);
const deserialized = JSON.parse(serialized);
// Brand 情報は失われるが、実行時の動作に影響なし
Type Guard とバリデーション関数の設計
効率的な Type Guard とバリデーション関数の設計は、開発体験を大きく左右します。
高性能な Type Guard の実装
typescript// 効率的な Type Guard の設計パターン
export const TypeGuards = {
// シンプルな形式チェック
isUserId: (value: string): value is UserId => {
return value.startsWith('user_') && value.length > 5;
},
// 正規表現を使用した厳密なチェック
isEmail: (value: string): value is Email => {
// 事前にコンパイルした正規表現を使用(パフォーマンス向上)
return EMAIL_REGEX.test(value);
},
// 複合条件のチェック
isValidPrice: (value: number): value is Currency => {
return (
Number.isFinite(value) &&
value >= 0 &&
Math.round(value * 100) === value * 100
);
},
// 配列の Type Guard
isUserIdArray: (values: string[]): values is UserId[] => {
return values.every(TypeGuards.isUserId);
},
} as const;
// 正規表現の事前コンパイル
const EMAIL_REGEX =
/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
バリデーション結果の型安全な処理
typescript// Result パターンによるエラーハンドリング
type ValidationResult<T> =
| { success: true; data: T }
| { success: false; error: string };
export const SafeValidators = {
email: (input: string): ValidationResult<Email> => {
try {
const email = createEmail(input);
return { success: true, data: email };
} catch (error) {
return {
success: false,
error:
error instanceof Error
? error.message
: 'Validation failed',
};
}
},
userId: (input: string): ValidationResult<UserId> => {
try {
const userId = IdUtils.user(input);
return { success: true, data: userId };
} catch (error) {
return {
success: false,
error:
error instanceof Error
? error.message
: 'Validation failed',
};
}
},
// 複数値のバリデーション
userIds: (
inputs: string[]
): ValidationResult<UserId[]> => {
const results: UserId[] = [];
for (let i = 0; i < inputs.length; i++) {
const result = SafeValidators.userId(inputs[i]);
if (!result.success) {
return {
success: false,
error: `Invalid user ID at index ${i}: ${result.error}`,
};
}
results.push(result.data);
}
return { success: true, data: results };
},
} as const;
エラーメッセージの改善
Brand Types を使用する際のエラーメッセージは、開発者の理解を助ける重要な要素です。
カスタムエラークラスの実装
typescript// Brand Types 専用のエラークラス
export class BrandTypeError extends Error {
constructor(
public readonly expectedType: string,
public readonly actualValue: unknown,
public readonly validationRule?: string
) {
super(
BrandTypeError.createMessage(
expectedType,
actualValue,
validationRule
)
);
this.name = 'BrandTypeError';
}
private static createMessage(
expectedType: string,
actualValue: unknown,
validationRule?: string
): string {
const baseMessage = `Expected ${expectedType}, received: ${actualValue}`;
return validationRule
? `${baseMessage}. Validation rule: ${validationRule}`
: baseMessage;
}
}
// 改善されたバリデーション関数
export function createEmailWithBetterError(
input: string
): Email {
if (typeof input !== 'string') {
throw new BrandTypeError(
'Email',
input,
'Must be a string'
);
}
if (!input.includes('@')) {
throw new BrandTypeError(
'Email',
input,
'Must contain @ symbol'
);
}
const emailRegex =
/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
if (!emailRegex.test(input)) {
throw new BrandTypeError(
'Email',
input,
'Must match valid email format (example: user@domain.com)'
);
}
return input as Email;
}
IDE サポートとオートコンプリート
IDE での開発体験を最適化するための工夫を紹介します。
JSDoc を活用した型情報の充実
typescript/**
* ユーザーIDを表すBrand Type
* @example
* ```typescript
* const userId = IdUtils.user('user_123');
* await userService.findById(userId);
* ```
*/
export type UserId = string & { readonly [USER_ID]: never };
/**
* 有効なメールアドレスを表すBrand Type
* @example
* ```typescript
* const email = createEmail('user@example.com');
* await emailService.send(email, 'Hello!');
* ```
*/
export type Email = string & {
readonly [EMAIL_BRAND]: never;
};
/**
* ユーザーIDを安全に生成します
* @param value - 'user_' で始まる文字列
* @throws {BrandTypeError} 形式が正しくない場合
* @example
* ```typescript
* const userId = IdUtils.user('user_12345');
* ```
*/
export function createUserId(value: string): UserId {
if (!value.startsWith('user_') || value.length < 6) {
throw new BrandTypeError(
'UserId',
value,
"Must start with 'user_' and have at least 6 characters"
);
}
return value as UserId;
}
TypeScript の厳密な型チェック設定
json// tsconfig.json での最適化設定
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
// Brand Types での型推論を向上させる設定
"exactOptionalPropertyTypes": true,
"useUnknownInCatchVariables": true
}
}
大規模プロジェクトでの運用パターン
Brand Types を大規模なプロジェクトで効果的に運用するためには、チーム全体での一貫した方針とベストプラクティスが必要です。
チーム開発での Brand Types 規約
命名規約とファイル構成
typescript// types/brands/index.ts - Brand Types の一元管理
export * from './ids';
export * from './primitives';
export * from './dates';
export * from './business';
// types/brands/ids.ts - ID系のBrand Types
declare const USER_ID: unique symbol;
declare const PRODUCT_ID: unique symbol;
declare const ORDER_ID: unique symbol;
export type UserId = string & { readonly [USER_ID]: never };
export type ProductId = string & {
readonly [PRODUCT_ID]: never;
};
export type OrderId = string & {
readonly [ORDER_ID]: never;
};
// types/brands/primitives.ts - プリミティブ型のBrand Types
declare const EMAIL: unique symbol;
declare const URL: unique symbol;
declare const PHONE_NUMBER: unique symbol;
export type Email = string & { readonly [EMAIL]: never };
export type ValidUrl = string & { readonly [URL]: never };
export type PhoneNumber = string & {
readonly [PHONE_NUMBER]: never;
};
// utils/validators/index.ts - バリデーション関数の一元管理
export * from './id-validators';
export * from './primitive-validators';
export * from './business-validators';
一貫したエラーハンドリング戦略
typescript// errors/brand-errors.ts - エラーハンドリングの統一
export class BrandValidationError extends Error {
constructor(
public readonly brandType: string,
public readonly inputValue: unknown,
public readonly reason: string,
public readonly suggestions?: string[]
) {
super(`${brandType} validation failed: ${reason}`);
this.name = 'BrandValidationError';
}
}
// utils/error-factory.ts - エラー生成の統一
export const BrandErrorFactory = {
invalidUserId: (value: unknown): BrandValidationError =>
new BrandValidationError(
'UserId',
value,
'Invalid format',
[
'Must start with "user_"',
'Must be at least 6 characters',
]
),
invalidEmail: (value: unknown): BrandValidationError =>
new BrandValidationError(
'Email',
value,
'Invalid email format',
['Must contain @ symbol', 'Must have valid domain']
),
} as const;
ライブラリ設計での活用
パッケージとしての Brand Types 公開
typescript// packages/core-types/src/index.ts
export type { UserId, ProductId, OrderId } from './brands/ids';
export type { Email, ValidUrl, PhoneNumber } from './brands/primitives';
export { IdValidators } from './validators/id-validators';
export { PrimitiveValidators } from './validators/primitive-validators';
export { BrandValidationError } from './errors/brand-errors';
// packages/core-types/package.json
{
"name": "@company/core-types",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
},
"./brands/*": {
"types": "./dist/brands/*.d.ts",
"import": "./dist/brands/*.mjs",
"require": "./dist/brands/*.js"
}
}
}
バージョン管理と互換性
typescript// 後方互換性を保った型の拡張
// v1.0.0
export type UserId = string & { readonly brand: 'UserId' };
// v1.1.0 - 非破壊的な拡張
export type UserId = string & {
readonly brand: 'UserId';
readonly version?: '1.1.0';
};
// v2.0.0 - 破壊的変更の場合の移行戦略
export type UserIdV2 = string & {
readonly brand: 'UserIdV2';
readonly format: 'uuid' | 'sequential';
};
// 移行期間中の互換性維持
export type UserId = UserIdV1 | UserIdV2;
type UserIdV1 = string & { readonly brand: 'UserId' };
export function migrateUserId(oldId: UserIdV1): UserIdV2 {
// 移行ロジック
return oldId as unknown as UserIdV2;
}
マイグレーション戦略
段階的導入のアプローチ
typescript// Phase 1: 既存コードとの共存
namespace Migration {
// 既存の string 型を徐々に Brand Types に置き換え
export function gradualMigration() {
// 既存関数
function legacyGetUser(id: string): User {
return userRepository.findById(id);
}
// 新しい Brand Types 対応関数
function modernGetUser(id: UserId): User {
return userRepository.findById(id);
}
// 移行期間中のアダプター関数
function adaptiveGetUser(id: string | UserId): User {
const userId =
typeof id === 'string' && !isUserId(id)
? IdValidators.user(id)
: (id as UserId);
return modernGetUser(userId);
}
}
}
// Phase 2: 段階的な型強化
export class UserServiceMigration {
// レガシーメソッド(非推奨マーク)
/** @deprecated Use findByIdSafe instead */
async findById(id: string): Promise<User | null> {
return this.findByIdSafe(IdValidators.user(id));
}
// 新しい型安全なメソッド
async findByIdSafe(id: UserId): Promise<User | null> {
// 実装
return null;
}
}
テスト戦略とモック対応
Brand Types 用のテストユーティリティ
typescript// test-utils/brand-mocks.ts
export const BrandMocks = {
// モック用の Brand Types 生成
userId: (suffix = '123'): UserId =>
`user_${suffix}` as UserId,
productId: (suffix = '456'): ProductId =>
`product_${suffix}` as ProductId,
email: (user = 'test', domain = 'example.com'): Email =>
`${user}@${domain}` as Email,
// 配列生成
userIds: (count: number): UserId[] =>
Array.from({ length: count }, (_, i) =>
BrandMocks.userId(String(i))
),
// ランダム生成
randomUserId: (): UserId =>
BrandMocks.userId(
Math.random().toString(36).substring(7)
),
} as const;
// test-utils/brand-matchers.ts - Jest カスタムマッチャー
declare global {
namespace jest {
interface Matchers<R> {
toBeValidUserId(): R;
toBeValidEmail(): R;
}
}
}
expect.extend({
toBeValidUserId(received: unknown) {
const pass =
typeof received === 'string' && isUserId(received);
return {
pass,
message: () =>
pass
? `Expected ${received} not to be a valid UserId`
: `Expected ${received} to be a valid UserId`,
};
},
toBeValidEmail(received: unknown) {
const pass =
typeof received === 'string' && isEmail(received);
return {
pass,
message: () =>
pass
? `Expected ${received} not to be a valid Email`
: `Expected ${received} to be a valid Email`,
};
},
});
型安全なテストケース
typescript// __tests__/user-service.test.ts
describe('UserService with Brand Types', () => {
let userService: UserService;
let mockRepository: jest.Mocked<UserRepository>;
beforeEach(() => {
mockRepository = createMockUserRepository();
userService = new UserService(mockRepository);
});
describe('findById', () => {
it('should find user by valid UserId', async () => {
// Arrange
const userId = BrandMocks.userId('123');
const expectedUser = createMockUser({ id: userId });
mockRepository.findById.mockResolvedValue(
expectedUser
);
// Act
const result = await userService.findById(userId);
// Assert
expect(result).toEqual(expectedUser);
expect(mockRepository.findById).toHaveBeenCalledWith(
userId
);
});
it('should throw error for invalid UserId format', async () => {
// Arrange & Act & Assert
expect(() => {
const invalidId = 'invalid_id' as UserId; // 型アサーションでの強制的な不正値
userService.findById(invalidId);
}).toThrow(BrandValidationError);
});
it('should handle type safety in async operations', async () => {
// Brand Types が非同期処理でも型安全性を保持することを確認
const userId = BrandMocks.userId('async');
const promise = userService.findById(userId);
// 型チェック:Promise<User | null> が正しく推論されることを確認
const result: User | null = await promise;
expect(result).toBeDefined();
});
});
describe('type safety validation', () => {
it('should prevent mixing different ID types', () => {
// コンパイル時エラーを実行時テストでシミュレート
const userId = BrandMocks.userId('123');
const productId = BrandMocks.productId('456');
// これらの操作が型エラーになることを確認
// 実際のテストではコンパイルが通らないため、型チェッカーが機能していることを確認
expect(userId).toBeValidUserId();
expect(productId).not.toBeValidUserId();
});
});
});
まとめ:Brand Types による型安全性の革命
TypeScript の Brand Types は、従来の構造的型システムの限界を打ち破り、より安全で保守性の高いコードを実現する強力な手法です。この記事で紹介した様々なパターンとベストプラクティスを通じて、あなたのプロジェクトでも以下のような劇的な改善を実現できるでしょう。
Brand Types がもたらす主要なメリット
# | メリット | 具体的な効果 | 適用場面 |
---|---|---|---|
1 | コンパイル時の型安全性 | ID の混同や不正な値の使用を事前に検出 | API 呼び出し、データベース操作 |
2 | ゼロランタイムコスト | パフォーマンスに一切影響せず型安全性を実現 | 大規模アプリケーション |
3 | ドメインロジックの表現力向上 | ビジネスルールを型レベルで表現 | DDD、複雑なビジネスロジック |
4 | IDE サポートの向上 | オートコンプリートとエラー検出の精度向上 | 日常的な開発作業 |
5 | リファクタリングの安全性 | 型による制約により安全な変更が可能 | レガシーコードの改善 |
実装における重要なポイント
1. 段階的導入の重要性
Brand Types は一度にすべてを導入する必要はありません。最も効果の高い部分から段階的に導入することで、チームへの負担を最小限に抑えながら効果を実感できます。
2. チーム全体での一貫性
Brand Types の効果を最大化するには、チーム全体で一貫した命名規約とパターンを採用することが重要です。規約の策定とドキュメント化に投資することで、長期的な開発効率が向上します。
3. バリデーションとエラーハンドリングの設計
型安全性を提供するだけでなく、適切なエラーメッセージと回復可能な処理を設計することで、開発者体験を大幅に向上させることができます。
将来への展望
TypeScript の Brand Types は、静的型付け言語の利点を JavaScript エコシステムに持ち込む革新的な手法です。今後、以下のような発展が期待されます:
- ツールサポートの向上: IDE やリンターでの Brand Types 専用サポート
- フレームワーク統合: React、Vue、Angular などでの公式サポート
- 標準化の進展: TypeScript 本体での Brand Types サポート検討
この記事で紹介した手法を活用して、より安全で保守性の高い TypeScript コードを実現してください。Brand Types は、単なる型安全性の向上を超えて、コードの品質とチームの開発体験を根本的に改善する力を持っています。
あなたのプロジェクトでも、今日から Brand Types を導入して、型安全性の新しい次元を体験してみてはいかがでしょうか。