TypeScript入門|型注釈・インターフェース・型安全性でJavaScriptをより堅牢にする方法


はじめに

前回の記事ではJavaScriptのモジュールシステムとViteを使ったフロントエンド開発環境の整え方を解説しました。Viteを使うとTypeScriptも設定なしで使えることに触れましたが、今回はそのTypeScriptを改めて取り上げます。「JavaScriptに型を付ける」というTypeScriptの基本的な考え方から、型注釈・インターフェース・型推論・ユニオン型・ジェネリクスまでを実践的なコード例で解説します。これまでの連載で書いてきたJavaScriptコードを型安全にする視点でお届けします。


1. TypeScriptとは何か

1.1 JavaScriptとの関係

TypeScriptはMicrosoftが開発したJavaScriptのスーパーセットです。TypeScriptで書いたコードはコンパイルして(または変換して)JavaScriptになります。

TypeScript(.ts)
     ↓ コンパイル(tscまたはVite・esbuildなど)
JavaScript(.js)
     ↓ ブラウザやNode.jsで実行

1.2 TypeScriptを使うメリット

メリット内容
型エラーを事前に発見できる実行前にコードの問題を検出できる
エディタの補完が強力になるVSCodeで型に基づいた自動補完が使える
コードの意図が伝わりやすい引数や戻り値の型がドキュメント代わりになる
リファクタリングが安全になる型チェックにより変更の影響範囲を把握できる

1.3 ViteでTypeScriptを使い始める

前回の記事でViteプロジェクトを作成する際、バリアントで「TypeScript」を選ぶだけで即座に使えます。

bash

# 既存のViteプロジェクトにTypeScriptを追加する場合
npm install -D typescript

# tsconfig.jsonを生成する
npx tsc --init
json

// tsconfig.json(基本設定)
{
    "compilerOptions": {
        "target":            "ES2020",       // 出力するJSのバージョン
        "module":            "ESNext",       // モジュール形式
        "moduleResolution":  "bundler",      // バンドラー向けの解決方法
        "strict":            true,           // 厳格な型チェックを有効にする
        "noImplicitAny":     true,           // 暗黙のany型を禁止する
        "skipLibCheck":      true,           // ライブラリの型チェックをスキップする
        "paths": {
            "@/*": ["./src/*"]              // パスエイリアス
        }
    },
    "include": ["src/**/*.ts"],
    "exclude": ["node_modules"]
}

💡 strict: true から始めることを強く推奨します: 厳格モードは最初は窮屈に感じますが、バグの多くを事前に防いでくれます。最初から true にしておくと後から有効化するよりも対応が楽です。


2. 基本的な型注釈

2.1 プリミティブ型

typescript

// 変数に型を付ける
let username:  string  = '山田太郎';
let age:       number  = 30;
let isActive:  boolean = true;
let nullValue: null    = null;
let undefinedValue: undefined = undefined;

// 型注釈なしでも型推論で型が決まる
let inferred = '型推論で string になる'; // string型として扱われる
let count    = 0;                        // number型として推論される

// ❌ 型が合わないと型エラーになる
let name: string = 42; // Error: Type 'number' is not assignable to type 'string'

2.2 配列型

// 配列の型注釈
let tags:    string[]  = ['CSS', 'JavaScript', 'WordPress'];
let scores:  number[]  = [95, 87, 100];
let flags:   boolean[] = [true, false, true];

// Array<T>という書き方もある(同じ意味)
let tags2: Array<string> = ['CSS', 'JavaScript'];

// タプル型(要素数と各要素の型を固定する)
let coordinate: [number, number] = [35.6895, 139.6917];
let nameAndAge: [string, number] = ['山田太郎', 30];

2.3 関数の型注釈

// 引数と戻り値に型を付ける
function add( a: number, b: number ): number {
    return a + b;
}

// アロー関数
const multiply = ( a: number, b: number ): number => a * b;

// オプショナル引数(?を付けると省略可能になる)
function greet( name: string, title?: string ): string {
    return title ? `${title} ${name}さん` : `${name}さん`;
}

greet( '山田太郎' );         // OK(titleは省略可能)
greet( '山田太郎', '様' );   // OK

// デフォルト引数
function createUser( name: string, role: string = 'viewer' ): object {
    return { name, role };
}

// 戻り値がない関数はvoidを使う
function logMessage( message: string ): void {
    console.log( message );
}

// 決して戻らない関数(エラーを投げる・無限ループ)はnever
function throwError( message: string ): never {
    throw new Error( message );
}

3. オブジェクト型とインターフェース

3.1 インラインでオブジェクトの型を定義する

// オブジェクトの型をインラインで定義する
function displayUser( user: { name: string; age: number; email: string } ): void {
    console.log( `${user.name}(${user.age}歳):${user.email}` );
}

// 使い回す場合はインターフェースで定義する

3.2 interfaceで型を定義する

// interfaceでオブジェクトの型を定義する
interface User {
    id:       number;
    name:     string;
    email:    string;
    age?:     number;    // ?を付けるとオプショナル(省略可能)
    readonly createdAt: string; // readonlyを付けると変更不可
}

// interfaceを使って変数に型を付ける
const user: User = {
    id:        1,
    name:      '山田太郎',
    email:     'yamada@example.com',
    createdAt: '2025-01-01',
};

// ageはオプショナルなので省略できる
const userWithoutAge: User = {
    id:        2,
    name:      '鈴木花子',
    email:     'suzuki@example.com',
    createdAt: '2025-02-01',
};

// readonlyは変更しようとするとエラーになる
user.createdAt = '2025-12-31'; // Error: Cannot assign to 'createdAt' because it is a read-only property

3.3 interfaceの拡張

// interfaceはextendsで拡張できる
interface Person {
    name:  string;
    email: string;
}

interface Employee extends Person {
    employeeId: string;
    department: string;
    startDate:  string;
}

interface Admin extends Employee {
    permissions: string[];
}

const admin: Admin = {
    name:        '田中一郎',
    email:       'tanaka@example.com',
    employeeId:  'EMP001',
    department:  'エンジニアリング',
    startDate:   '2023-04-01',
    permissions: ['user_manage', 'content_manage'],
};

3.4 typeエイリアスとの違い

// typeエイリアス(interfaceとよく似ている)
type Point = {
    x: number;
    y: number;
};

// typeはユニオン型・インターセクション型などにも使える
type StringOrNumber = string | number;
type ID             = string | number;

// interfaceはオブジェクト型の定義に向いている
// typeはプリミティブ型の別名やユニオン型に向いている

// interfaceはマージできる(同じ名前で追加定義できる)
interface Config {
    theme: string;
}

interface Config {
    language: string;
}
// → { theme: string; language: string; } として扱われる

💡 オブジェクトの形を定義するときは interface・それ以外は type が一般的な使い分けです: どちらでも書けるケースは多いですが、チームで統一することが大切です。Reactのコンポーネントpropsには interface が使われることが多いです。


4. ユニオン型とリテラル型

4.1 ユニオン型(|)

// どちらかの型を受け付ける
type StringOrNumber = string | number;

function printId( id: StringOrNumber ): void {
    if ( typeof id === 'string' ) {
        console.log( `文字列のID: ${id.toUpperCase()}` );
    } else {
        console.log( `数値のID: ${id.toFixed()}` );
    }
}

printId( 'abc-123' ); // 文字列のID: ABC-123
printId( 42 );        // 数値のID: 42

// nullableな型(nullまたはundefinedを許容する)
type NullableString = string | null;
type MaybeNumber   = number | undefined;

function findUser( id: number ): User | null {
    // 見つからない場合はnullを返す
    return null;
}

4.2 リテラル型

// 特定の値のみを許容する型
type Theme    = 'light' | 'dark' | 'system';
type Status   = 'pending' | 'fulfilled' | 'rejected';
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
type Direction = 'top' | 'right' | 'bottom' | 'left';

// リテラル型を引数に使う
function setTheme( theme: Theme ): void {
    document.documentElement.setAttribute( 'data-theme', theme );
}

setTheme( 'dark' );  // OK
setTheme( 'blue' );  // Error: Argument of type '"blue"' is not assignable to type 'Theme'

// 数値のリテラル型
type DiceValue = 1 | 2 | 3 | 4 | 5 | 6;
type ZeroToHundred = 0 | 25 | 50 | 75 | 100; // 離散値

4.3 判別可能なユニオン型(Discriminated Union)

// 共通のプロパティで型を判別する
interface SuccessResponse {
    status: 'success';      // リテラル型で判別する
    data:   unknown;
    message: string;
}

interface ErrorResponse {
    status:    'error';     // リテラル型で判別する
    errorCode: number;
    message:   string;
}

type ApiResponse = SuccessResponse | ErrorResponse;

function handleResponse( response: ApiResponse ): void {
    // statusの値でTypeScriptが型を絞り込む(型ガード)
    if ( response.status === 'success' ) {
        console.log( response.data );      // SuccessResponseとして扱われる
    } else {
        console.log( response.errorCode ); // ErrorResponseとして扱われる
    }
}

5. ジェネリクス

5.1 ジェネリクスの基本

// 型を引数のように受け取れる(ジェネリクス)
// ❌ 型が固定されてしまう
function getFirst_string( arr: string[] ): string {
    return arr[0];
}

function getFirst_number( arr: number[] ): number {
    return arr[0];
}

// ✅ ジェネリクスを使うと型を汎用化できる
function getFirst<T>( arr: T[] ): T {
    return arr[0];
}

const firstString = getFirst( ['CSS', 'JS', 'TS'] ); // string型と推論される
const firstNumber = getFirst( [1, 2, 3] );            // number型と推論される
const firstUser   = getFirst<User>( users );           // User型

5.2 前回のコードをジェネリクスで型付けする

// storage.tsのgetメソッドをジェネリクスで型安全にする
interface StorageWrapper {
    set<T>( key: string, value: T ): boolean;
    get<T>( key: string, defaultValue?: T ): T | null;
    remove( key: string ): void;
    has( key: string ): boolean;
}

const storage: StorageWrapper = {
    set<T>( key: string, value: T ): boolean {
        try {
            localStorage.setItem( key, JSON.stringify( value ) );
            return true;
        } catch {
            return false;
        }
    },

    get<T>( key: string, defaultValue: T | null = null ): T | null {
        try {
            const item = localStorage.getItem( key );
            if ( item === null ) return defaultValue;
            return JSON.parse( item ) as T;
        } catch {
            return defaultValue;
        }
    },

    remove( key: string ): void {
        localStorage.removeItem( key );
    },

    has( key: string ): boolean {
        return localStorage.getItem( key ) !== null;
    },
};

// 使い方(型が安全になる)
interface UserPreference {
    theme:    string;
    language: string;
    fontSize: number;
}

storage.set<UserPreference>( 'pref', { theme: 'dark', language: 'ja', fontSize: 16 } );
const pref = storage.get<UserPreference>( 'pref' ); // UserPreference | null として型付けられる

5.3 非同期関数のジェネリクス

// apiRequest関数を型安全にする
async function apiRequest<T>( endpoint: string, options: RequestInit = {} ): Promise<T> {
    const response = await fetch( `https://api.example.com${endpoint}`, options );

    if ( !response.ok ) {
        throw new Error( `HTTP Error: ${response.status}` );
    }

    return response.json() as Promise<T>;
}

// 使い方
interface Post {
    id:    number;
    title: string;
    body:  string;
}

const posts = await apiRequest<Post[]>( '/posts' );   // Post[]型
const post  = await apiRequest<Post>( '/posts/1' );   // Post型

// TypeScriptがプロパティの補完を提供してくれる
console.log( post.title ); // 補完が効く
console.log( post.body );  // 補完が効く

6. 型ユーティリティ

6.1 TypeScript組み込みのユーティリティ型

interface User {
    id:       number;
    name:     string;
    email:    string;
    password: string;
    role:     'admin' | 'editor' | 'viewer';
}

// Partial:すべてのプロパティをオプショナルにする
type PartialUser = Partial<User>;
// → { id?: number; name?: string; email?: string; ... }

// Required:すべてのプロパティを必須にする
type RequiredUser = Required<PartialUser>;

// Readonly:すべてのプロパティを読み取り専用にする
type ReadonlyUser = Readonly<User>;

// Pick:特定のプロパティだけを抽出する
type PublicUser = Pick<User, 'id' | 'name' | 'role'>;
// → { id: number; name: string; role: 'admin' | 'editor' | 'viewer' }

// Omit:特定のプロパティを除外する
type SafeUser = Omit<User, 'password'>;
// → { id: number; name: string; email: string; role: ... }

// Record:キーと値の型を指定したオブジェクト型を作る
type ThemeColors = Record<string, string>;
const colors: ThemeColors = {
    primary:   '#1A5FC0',
    secondary: '#27AE60',
};

// ReturnType:関数の戻り値の型を取得する
function createUser() {
    return { id: 1, name: '山田太郎' };
}
type CreatedUser = ReturnType<typeof createUser>;
// → { id: number; name: string }

7. 実践:連載のコードをTypeScript化する

7.1 フォームバリデーションを型安全にする

// validation.ts

// バリデーターの型
type Validator = ( value: string ) => string | null;

// バリデーションルール
interface ValidationRule {
    validator: Validator;
    message?:  string;
}

// フィールドの設定
interface FieldConfig {
    rules: Validator[];
}

// バリデーション結果
interface ValidationResult {
    isValid: boolean;
    errors:  Record<string, string[]>;
}

// validators
const validators = {
    required: (): Validator => ( value: string ) =>
        value.trim() !== '' ? null : 'この項目は必須です。',

    email: (): Validator => ( value: string ) => {
        if ( !value.trim() ) return null;
        return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test( value )
            ? null
            : '正しいメールアドレスを入力してください。';
    },

    minLength: ( min: number ): Validator => ( value: string ) => {
        if ( !value.trim() ) return null;
        return value.length >= min ? null : `${min}文字以上で入力してください。`;
    },

    maxLength: ( max: number ): Validator => ( value: string ) =>
        value.length <= max ? null : `${max}文字以内で入力してください。`,
};

// フォーム全体のバリデーション
function validateForm(
    values:  Record<string, string>,
    configs: Record<string, FieldConfig>
): ValidationResult {
    const errors: Record<string, string[]> = {};
    let isValid = true;

    for ( const [field, config] of Object.entries( configs ) ) {
        const value       = values[field] ?? '';
        const fieldErrors: string[] = [];

        for ( const rule of config.rules ) {
            const error = rule( value );
            if ( error ) fieldErrors.push( error );
        }

        if ( fieldErrors.length > 0 ) {
            errors[field] = fieldErrors;
            isValid       = false;
        }
    }

    return { isValid, errors };
}

export { validators, validateForm };
export type { ValidationResult, FieldConfig };

7.2 型安全なAPIクライアント

// api/client.ts

interface ApiConfig {
    baseUrl:  string;
    timeout?: number;
    headers?: Record<string, string>;
}

interface ApiError extends Error {
    status:   number;
    response: Response;
}

class ApiClient {
    private config: Required<ApiConfig>;

    constructor( config: ApiConfig ) {
        this.config = {
            baseUrl:  config.baseUrl,
            timeout:  config.timeout ?? 10000,
            headers:  config.headers ?? {},
        };
    }

    private async request<T>( endpoint: string, options: RequestInit = {} ): Promise<T> {
        const controller = new AbortController();
        const timeoutId  = setTimeout( () => controller.abort(), this.config.timeout );

        try {
            const response = await fetch(
                `${this.config.baseUrl}${endpoint}`,
                {
                    ...options,
                    headers: {
                        'Content-Type': 'application/json',
                        ...this.config.headers,
                        ...options.headers,
                    },
                    signal: controller.signal,
                }
            );

            if ( !response.ok ) {
                const error = new Error( `HTTP Error: ${response.status}` ) as ApiError;
                error.status   = response.status;
                error.response = response;
                throw error;
            }

            if ( response.status === 204 ) return null as unknown as T;
            return response.json() as Promise<T>;

        } finally {
            clearTimeout( timeoutId );
        }
    }

    get<T>( endpoint: string ): Promise<T> {
        return this.request<T>( endpoint );
    }

    post<T>( endpoint: string, data: unknown ): Promise<T> {
        return this.request<T>( endpoint, {
            method: 'POST',
            body:   JSON.stringify( data ),
        } );
    }

    put<T>( endpoint: string, data: unknown ): Promise<T> {
        return this.request<T>( endpoint, {
            method: 'PUT',
            body:   JSON.stringify( data ),
        } );
    }

    delete<T>( endpoint: string ): Promise<T> {
        return this.request<T>( endpoint, { method: 'DELETE' } );
    }
}

export default ApiClient;
export type { ApiConfig, ApiError };

8. 実践チェックリスト

tsconfig.jsonstrict: true を設定しているか

関数の引数と戻り値に型注釈を付けているか

オブジェクトの形は interface で定義しているか

nullundefined の可能性がある値に | null または | undefined を付けているか

リテラル型でテーマ・ステータスなどの定数値を型として定義しているか

汎用的な関数にはジェネリクス <T> を使って型を汎用化しているか

PartialPickOmit などのユーティリティ型を活用しているか

APIレスポンスの型を interface で定義してジェネリクスに渡しているか

VSCodeでエラーの波線がなくなっているか(型エラーがないか)


まとめ

今回はTypeScriptの基本的な型注釈からインターフェース・ジェネリクス・型ユーティリティまでを解説しました。ポイントをまとめると:

  • TypeScriptはJavaScriptのスーパーセットでコンパイルするとJavaScriptになる
  • strict: true から始めると多くのバグを事前に防げる
  • interface はオブジェクトの形を定義するのに使い extends で拡張できる
  • type はプリミティブ型の別名やユニオン型に向いている
  • リテラル型でテーマ・ステータスなどの限定値を型として表現できる
  • ジェネリクス <T> を使うと型を汎用化して安全な再利用可能コードを書ける
  • PartialPickOmitRecord などのユーティリティ型で型を加工できる
  • 連載で書いてきたAPIクライアント・バリデーション・ストレージも型安全にできる

次の記事では、ReactとViteを組み合わせてコンポーネントベースのUI開発を始める方法を解説します。お楽しみに!

コメント

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