🎃

TypeScriptって実はそんなに型安全じゃないよね、という話【初学者】

に公開

はじめに

最近Rustをさきっちょだけ味見してみたのですが、TypeScriptとの型システムの違いに驚愕しました。「TypeScript使ってるから型安全だぜ!」って思ってたんですが、ある程度は型安全かもしれませんが、実はそこまででもないんですね...

この記事では、TypeScriptがRustと比べてどれくらい「型が緩い」のかを、実際のコード例を交えて解説してみます。

そもそもTypeScriptはトランスパイルして実行している

実はTypeScriptって、JavaScriptのような言語そのものではないので直接実行できません。

JavaScriptならブラウザやNode.jsで直接実行できますが、TypeScriptはそうはいきません。まずはJavaScriptへトランスパイル(コンパイル)という工程が必要です。

TypeScriptの実行フロー

TypeScriptコード(.ts)

まず、TypeScriptで書いたコードがあります。

// user.ts
interface User {
    id: number;
    name: string;
    email: string;
}

function greetUser(user: User): string {
    return `こんにちは、${user.name}さん!`;
}

const myUser: User = {
    id: 1,
    name: "田中太郎",
    email: "tanaka@example.com"
};

console.log(greetUser(myUser));

このコードには型情報(Userインターフェース、user: UsermyUser: Userなど)が含まれています。

トランスパイル(コンパイル)

TypeScriptコンパイラ(tscなど)がTypeScriptコードをJavaScriptコードに変換します。

# コマンドラインでの実行例
npx tsc user.ts

変換されたJavaScript(.js)

トランスパイル後のJavaScriptコードはこんな感じになります。

// user.js(自動生成される)
function greetUser(user) {  // 型情報が消失
    return `こんにちは、${user.name}さん!`;
}

const myUser = {  // 型情報が消失
    id: 1,
    name: "田中太郎",
    email: "tanaka@example.com"
};

console.log(greetUser(myUser));

注目ポイント

  • interface Userが完全に消えている!
  • user: Useruserになっている
  • myUser: UsermyUserになっている

つまり、型情報は全部削除されて普通のJavaScriptになっちゃうんです。

実際の実行

最終的に実行されるのは生成されたJavaScriptファイルです。

# Node.jsで実行
node user.js

ブラウザの場合も、実際に読み込まれるのはJavaScriptファイルです。
つまりTypeScriptはJavaScriptを拡張して型情報を付けられるようにした言語です。

any型という名の罠

まず最初に紹介したいのが、TypeScriptの最大の罠(?)であるany型です。

// これ、TypeScriptで普通に通っちゃいます
let value: any = "hello";
value = 42;
value = { foo: "bar" };
value = [1, 2, 3];
value.someRandomMethod(); // コンパイルエラーにならない!
value.foo.bar.baz.qux; // 実行はできないが、これも通る!やばくない?

any型を使うと、もう何でもありです。型チェックが完全に無効化されます。これってつまりJavaScriptと変わらないってことですよね。
初心者のうちは「何が悪いの?」となるかもしれませんが、少しでもコード量が増えてくると分かると思います。

一方、Rustではこんなことはできません。

// Rust - こんなコードは絶対に書けない
let mut value = "hello";
value = 42; // コンパイルエラー!「型が違うよ!」って怒られます

Rustは型に対してめちゃくちゃ厳格で、一度決めた型は絶対に変更できません。

なぜany型が存在するのか

ちなみに、なぜTypeScriptにany型があるのかというと、JavaScriptからの移行を簡単にするためです。既存のJavaScriptコードを一気にTypeScriptに変換するのは大変なので、とりあえずanyを使って段階的に型を付けていけるようにしてあるんですね。

でも、最初からTypeScriptでプロジェクト作成しているのに、any型を使っていると、それはTypeScriptである必要がなくなります。

number型とかいう整数なのか浮動小数点数なのかわからん型

TypeScriptのnumber型、これもなかなか曲者です。一見便利そうなんですが、実はかなり大雑把な型なんですよね。

TypeScriptのnumber型は何でも受け入れ過ぎ問題

// TypeScript - これ全部number型として受け入れちゃいます
let age: number = 25;           // 整数
let height: number = 175.5;     // 浮動小数点数
let temperature: number = -10;  // 負の数
let pi: number = 3.14159;       // 円周率
let infinity: number = Infinity; // 無限大
let notANumber: number = NaN;   // Not a Number(なぜかnumber型)
let bigNumber: number = 9007199254740992; // でかい数

// さらに、こんな危険なことも...
function setUserAge(age: number) {
    // ユーザーの年齢を設定する関数のつもり
    console.log(`年齢を${age}歳に設定しました`);
}

setUserAge(25);        // OK - 普通
setUserAge(-5);        // えっ?負の年齢?
setUserAge(3.14);      // 3.14歳?
setUserAge(Infinity);  // 無限歳?
setUserAge(NaN);       // NaN歳??

これ、全部通っちゃうんです。TypeScriptから見ると「どれもnumberだから問題ないよ〜」って感じなんですが、実際のロジック的には明らかにおかしいですよね...

実際にありがちな問題

// 配列のインデックスを計算する関数
function getArrayElement<T>(array: T[], index: number): T | undefined {
    return array[index];
}

const users = ["田中", "佐藤", "鈴木"];

// これらが全部通っちゃう
console.log(getArrayElement(users, 1));     // OK - "佐藤"
console.log(getArrayElement(users, -1));    // 負のインデックス?undefined
console.log(getArrayElement(users, 3.14)); // 小数点のインデックス?undefined
console.log(getArrayElement(users, NaN));   // NaNのインデックス?undefined

配列のインデックスに小数点やNaNを渡しても、TypeScriptは「numberだからOK!」って言うんです。でも実際には意味不明ですよね。

Rustは整数と浮動小数点数をちゃんと区別します

// Rust - 型がめちゃくちゃ細かく分かれてる
let age: u8 = 25;          // 0-255の符号なし8bit整数
let height: f32 = 175.5;   // 32bit浮動小数点数
let temperature: i8 = -10; // -128〜127の符号付き8bit整数
let big_number: u64 = 9007199254740992; // 64bit符号なし整数

// 配列のインデックスは厳密にusizeじゃないとダメ
fn get_array_element<T>(array: &[T], index: usize) -> Option<&T> {
    array.get(index)
}

let users = vec!["田中", "佐藤", "鈴木"];

// これは通る
println!("{:?}", get_array_element(&users, 1)); // Some("佐藤")

// これらはコンパイルエラー!
get_array_element(&users, -1);    // エラー!usizeは負の数を受け付けない
get_array_element(&users, 3.14); // エラー!f64はusizeじゃない
get_array_element(&users, NaN);  // そもそもRustにNaNを直接渡せない

Rustでは、用途に応じて適切な型を選ぶ必要があります。だから「年齢に負の数を設定」みたいなバグは、コンパイル時点で発見できるんです。

TypeScriptでもできるだけ安全に書く方法

もちろん、TypeScriptでももう少し安全に書く方法はあります

// Branded typeで制約を表現
type Age = number & { __brand: "Age" };
type ArrayIndex = number & { __brand: "ArrayIndex" };

// 年齢を作る関数(バリデーション付き)
function createAge(value: number): Age | null {
    if (value >= 0 && value <= 150 && Number.isInteger(value)) {
        return value as Age;
    }
    return null;
}

// 配列インデックスを作る関数
function createArrayIndex(value: number): ArrayIndex | null {
    if (value >= 0 && Number.isInteger(value)) {
        return value as ArrayIndex;
    }
    return null;
}

// 型安全な関数
function setUserAge(age: Age) {
    console.log(`年齢を${age}歳に設定しました`);
}

function getArrayElement<T>(array: T[], index: ArrayIndex): T | undefined {
    return array[index];
}

// 使用例
const validAge = createAge(25);
if (validAge) {
    setUserAge(validAge); // OK
}

const invalidAge = createAge(-5);
if (invalidAge) {
    setUserAge(invalidAge); // ここには到達しない
}

setUserAge(30 as Age); // 型アサーションすれば通るが、危険

でも、これって毎回書くの面倒くさいですよね...しかも、型アサーションを使えば結局回避できちゃうし。

なぜnumber型はこんな仕様なのか?

これもJavaScriptとの互換性のためなんです。JavaScriptにはもともとnumber型しかないので、TypeScriptもそれに合わせているんですね。

// JavaScript - そもそも数値型は1つだけ
let value = 42;        // number
let decimal = 3.14;    // number
let negative = -10;    // number
let inf = Infinity;    // number
let nan = NaN;         // number

JavaScript自体が「数値は全部number」という仕様なので、TypeScriptもそれを引き継いでいるわけです。

実際の開発での対策

現実的には、こんな感じで対策することが多いです

// 1. JSDocで制約を明記
/**
 * ユーザーの年齢を設定
 * @param age 0以上150以下の整数
 */
function setUserAge(age: number) {
    if (age < 0 || age > 150 || !Number.isInteger(age)) {
        throw new Error(`無効な年齢: ${age}`);
    }
    console.log(`年齢を${age}歳に設定しました`);
}

// 2. バリデーション関数を作る
function isValidAge(value: number): boolean {
    return value >= 0 && value <= 150 && Number.isInteger(value);
}

function isValidArrayIndex(value: number): boolean {
    return value >= 0 && Number.isInteger(value);
}

// 3. ライブラリを使う(zodの例)
import { z } from "zod";

const AgeSchema = z.number().int().min(0).max(150);
const ArrayIndexSchema = z.number().int().min(0);

type Age = z.infer<typeof AgeSchema>;
type ArrayIndex = z.infer<typeof ArrayIndexSchema>;

function setUserAgeWithZod(age: Age) {
    console.log(`年齢を${age}歳に設定しました`);
}

// 使用時
const ageResult = AgeSchema.safeParse(25);
if (ageResult.success) {
    setUserAgeWithZod(ageResult.data);
}

すこし面倒ですが、こうすることでnumber型を安全に扱うことができます。
でも、結局バリデーションするんだったら、型を定義して変数を初期化する意味とは...ってなるので、JS/TSのnumber型は結構不満がある点の一つです。

仕方がないのですが、TypeScriptのnumber型はゆるゆるなので、使う時は「number型の制約は自分で管理する」という意識を持つことが大切ですね!

しかし例に上げた年齢を設定するような例ではRustであっても、仮にTSにそのような型があっても、普通にバリデーションすべきです

Error型とかいう何でも受け入れる緩すぎる型

TypeScriptのエラーハンドリングも、実はかなり緩いんです。特にErrorクラスとthrow/catchの仕組みが問題です。

TypeScriptのエラーハンドリングの問題

// TypeScript - 何でもthrowできちゃう
throw "文字列エラー";           // string
throw 42;                     // number
throw { message: "オブジェクト" }; // object
throw null;                   // null
throw undefined;              // undefined
throw new Error("普通のエラー"); // Error

// さらに、Errorコンストラクタも緩い
const error1 = new Error();              // messageなし
const error2 = new Error(undefined);     // undefinedでもOK
const error3 = new Error(null as any);   // nullでもOK
const error4 = new Error(123 as any);    // numberでもOK

JavaScriptの仕様上、throw文は何でも投げることができます。TypeScriptもその仕様を引き継いでいるので、型安全性が全くありません。

catchで受け取る値の型問題

// catchで受け取る値はunknown(昔はany)
try {
    someFunction(); // この関数が何をthrowするか分からない
} catch (error) {
    // errorはunknown型
    console.log(error.message); // エラー!unknownなのでmessageプロパティが存在するか分からない
    
    // 仕方ないので型ガードや型アサーション
    if (error instanceof Error) {
        console.log(error.message); // やっとアクセスできる
    } else {
        console.log("不明なエラー:", error);
    }
}

catchで受け取る値はunknown型なので、エラーの詳細にアクセスするには毎回型チェックが必要です。面倒くさいですよね...

実際によくある問題

// APIエラーのハンドリング例
async function fetchUser(id: number): Promise<User> {
    try {
        const response = await fetch(`/api/users/${id}`);
        if (!response.ok) {
            throw new Error(`HTTP ${response.status}`); // string message
        }
        return await response.json();
    } catch (error) {
        // ここでerrorが何の型か分からない
        if (error instanceof Error) {
            throw new Error(`ユーザー取得失敗: ${error.message}`);
        } else {
            throw new Error("不明なエラーが発生しました");
        }
    }
}

// さらに複雑な例
function processData(data: unknown) {
    try {
        return JSON.parse(data as string);
    } catch (error) {
        // JSON.parseは何をthrowするか?SyntaxError?それとも?
        if (error instanceof SyntaxError) {
            throw new Error("JSONの形式が不正です");
        } else if (error instanceof TypeError) {
            throw new Error("データが文字列ではありません");
        } else {
            throw new Error("データの処理中にエラーが発生しました");
        }
    }
}

エラーの種類や詳細が型レベルで表現されていないので、どんなエラーが投げられるか予測できません。

Rustの型安全なエラーハンドリング

一方、RustではResult<T, E>型を使って、エラーを型安全に扱います:

// Rust - エラーも型で表現
use std::fs::File;
use std::io;

// Result<T, E>でエラーの可能性を型に含める
fn read_file(path: &str) -> Result<String, io::Error> {
    match File::open(path) {
        Ok(mut file) => {
            let mut content = String::new();
            match file.read_to_string(&mut content) {
                Ok(_) => Ok(content),
                Err(e) => Err(e), // io::Errorが返される
            }
        },
        Err(e) => Err(e), // io::Errorが返される
    }
}

// カスタムエラー型も定義できる
#[derive(Debug)]
enum UserError {
    NotFound,
    InvalidId,
    NetworkError(String),
}

fn find_user(id: u32) -> Result<User, UserError> {
    if id == 0 {
        return Err(UserError::InvalidId);
    }
    // ユーザー検索ロジック...
    Err(UserError::NotFound) // 例として
}

// エラーハンドリングが強制される
fn main() {
    match find_user(123) {
        Ok(user) => println!("ユーザー見つかった: {:?}", user),
        Err(UserError::NotFound) => println!("ユーザーが見つかりません"),
        Err(UserError::InvalidId) => println!("無効なID"),
        Err(UserError::NetworkError(msg)) => println!("ネットワークエラー: {}", msg),
    }
    // 全てのエラーケースを処理しないとコンパイルエラー!
}

Rustでは

  • エラーの型が明確:何のエラーが起こりうるかが型で分かる
  • エラーハンドリングが強制:Resultを無視するとコンパイルエラー
  • 網羅的なチェック:全てのエラーケースを処理しないとエラー

TypeScriptでもう少し安全にエラーハンドリング

TypeScriptでも、工夫すればもう少し安全にできます:

// 1. カスタムエラークラスを定義
class ValidationError extends Error {
    constructor(message: string, public field: string) {
        super(message);
        this.name = "ValidationError";
    }
}

class NetworkError extends Error {
    constructor(message: string, public statusCode: number) {
        super(message);
        this.name = "NetworkError";
    }
}

// 2. Result型っぽいものを自作
type Result<T, E = Error> = 
    | { success: true; data: T }
    | { success: false; error: E };

function parseUser(input: string): Result<User, ValidationError> {
    try {
        const data = JSON.parse(input);
        if (!data.name) {
            return {
                success: false,
                error: new ValidationError("名前が必要です", "name")
            };
        }
        return { success: true, data: data as User };
    } catch {
        return {
            success: false,
            error: new ValidationError("JSONの形式が不正です", "input")
        };
    }
}

// 使用例
const result = parseUser(jsonString);
if (result.success) {
    console.log(result.data.name); // 型安全!
} else {
    console.log(`エラー: ${result.error.message} (フィールド: ${result.error.field})`);
}

// 3. ライブラリを使う(neverthrowの例)
import { Result, ok, err } from 'neverthrow';

function divideNumbers(a: number, b: number): Result<number, string> {
    if (b === 0) {
        return err("0で割ることはできません");
    }
    return ok(a / b);
}

const result2 = divideNumbers(10, 2);
result2
    .map(value => value * 2)  // 成功時の処理
    .mapErr(error => `計算エラー: ${error}`)  // エラー時の処理
    .match(
        value => console.log(`結果: ${value}`),
        error => console.log(error)
    );

なぜこんな仕様なのか?

これもJavaScriptとの互換性のためです。JavaScriptでは昔から「何でもthrowできる」仕様だったので、TypeScriptもそれを引き継いでcatchした例外はanyやunknown型になっています。

// JavaScript - 昔からこの仕様
try {
    throw "文字列エラー";
} catch (e) {
    console.log(e); // 何が来るか分からない
}

実際の開発での対策

まだ実際には実践していませんがエラーについてAIに聞いたところ以下のような対策があるようです。

// 1. エラークラスを統一
class AppError extends Error {
    constructor(
        message: string,
        public code: string,
        public statusCode: number = 500
    ) {
        super(message);
        this.name = "AppError";
    }
}

// 2. エラーハンドリングのユーティリティ
function handleError(error: unknown): AppError {
    if (error instanceof AppError) {
        return error;
    } else if (error instanceof Error) {
        return new AppError(error.message, "UNKNOWN_ERROR");
    } else {
        return new AppError("不明なエラーが発生しました", "UNKNOWN_ERROR");
    }
}

// 3. try-catchを関数でラップ
async function safeAsync<T>(
    operation: () => Promise<T>
): Promise<Result<T, AppError>> {
    try {
        const data = await operation();
        return { success: true, data };
    } catch (error) {
        return { success: false, error: handleError(error) };
    }
}

// 使用例
const userResult = await safeAsync(() => fetchUser(123));
if (userResult.success) {
    console.log(userResult.data.name);
} else {
    console.log(`エラー [${userResult.error.code}]: ${userResult.error.message}`);
}

TypeScriptのエラーの扱いは、超曲者なので、できるならカスタムエラークラスをしっかり作ったり、RustのようなResult型を使うのが良いかもしれません。

型アサーション 「俺を信じろ!」システム

TypeScriptにはasキーワードを使った「型アサーション」という機能があります。これもなかなか危険です。

// API呼び出しでよくやりがち
async () => {
  const response = await fetch("/api/user");
  const user = await response.json() as User;
  console.log(user.name); // Userの形になってない可能性があるのに...
}

// さらに極悪な例
const data = "hello" as any as number;
console.log(data.toFixed(2)); // 実行時エラー確定

// 型の変換を強制
const id = "123" as unknown as number;
console.log(id + 1); // 実行時に "1231" になる

型アサーションは「俺がこの型だって言ってるんだから信じろ!」とコンパイラに強制的に信じさせる機能です。でも、プログラマーが間違ってることなんてよくありますよね...(体験談)

上記のAPI呼び出しの例では、APIレスポンスでエラーが返ってくる場合や、期待したデータ構造と違う場合に実行時エラーになる可能性があります。

Rustの安全なアプローチ

Rustでは、このような「信じろ」系の操作は明示的にunsafeと宣言したブロック内でしか実行できません

// Rust - 危険な操作は明示的にunsafeブロック内で
let value: i32 = 42;
let ptr = &value as *const i32;

unsafe {
    let dangerous_value = *ptr; // 「この中の型は保証されてないよ!」と明示されている
}

unsafeって名前からして危険そうですよね。TypeScriptのasは見た目が無害なので、つい使いすぎちゃうんです。

型アサーションが比較的安全な場面

とはいえ、型アサーションが必要(または比較的安全)な場面もあります

// 1. DOMの型を特定したい場合(要素の存在が確実な場合)
const canvas = document.getElementById("myCanvas") as HTMLCanvasElement;
// ※ ただし、要素が存在しない可能性がある場合は危険

// 2. ライブラリの型定義が不完全な場合
interface Config {
  // ...
}
const config = JSON.parse(configString) as Config; // 型アサーションで型を指定

// 3. 型の幅を狭める場合(安全)
const element = event.target as HTMLElement; // EventTargetからHTMLElementに

ただし、これらの場面でも、より安全な書き方があります

// より安全なDOMの扱い方
const canvas = document.getElementById("myCanvas");
if (canvas instanceof HTMLCanvasElement) {
    // ここで初めて安全にcanvasを使える
    const ctx = canvas.getContext("2d");
}

// より安全なAPI呼び出し
const response = await fetch("/api/user");
const data = await response.json();

// 型ガードで安全にチェック
function isUser(value: unknown): value is User {
    return (
        typeof value === "object" &&
        value !== null &&
        typeof (value as any).id === "number" &&
        typeof (value as any).name === "string"
    );
}

if (isUser(data)) {
    console.log(data.name); // 安全!
} else {
    console.error("無効なユーザーデータ");
}

型ガードを使った方が確実で安全ですが、面倒くさがってついついasを使っちゃうんですよね...

型アサーションを使う時の心構え

型アサーションを使う時は「これは危険な操作だ」という意識を持つことが大切です。

  • 本当にその型だと確信できるか?
  • 実行時にエラーになったらどうするか?
  • より安全な書き方はないか?

TypeScriptの型アサーションは便利ですが、Rustのunsafeのように「危険性」が名前に表れていないのが問題なんですよね...

Non-null assertion operator:!という危険な記号

TypeScriptには!演算子というものがあります。これは「この値は絶対にnullやundefinedじゃないから!」とコンパイラに伝える演算子です。

// よくあるパターン
function getUserName(user?: User): string {
    return user!.name; // userがundefinedかもしれないのに...
}

// DOM操作でも
const element = document.getElementById("myElement")!;
element.style.display = "none"; // elementがnullだったら?

// 配列でも
const users: User[] = getUsers();
const firstUser = users[0]!; // 配列が空だったら?

この!演算子、見た目からして危険そうじゃないですか?

Rustでは、null安全性が言語レベルで保証されています。

// Rust - Option型で明示的にnullableな値を扱う
fn get_user_name(user: Option<User>) -> Option<String> {
    user.map(|u| u.name) // 安全に処理される
}

// より詳しく書くとこんな感じ
fn get_user_name_verbose(user: Option<User>) -> Option<String> {
    match user {
        Some(u) => Some(u.name),
        None => None, // nullの場合も明示的に処理
    }
}

RustのOption<T>型は「値があるかもしれないし、ないかもしれない」ということを型レベルで表現します。これにより、null参照エラーが起こりようがないんです。すごくないですか?

TypeScriptでも安全に書く方法

TypeScriptでも、もちろん安全に書く方法はあります

// Optional chaining(?. 演算子)を使う
function getUserName(user?: User): string | undefined {
    return user?.name;
}

// Nullish coalescing(?? 演算子)と組み合わせる
function getUserNameSafe(user?: User): string {
    return user?.name ?? "Unknown";
}

// 型ガードを使う
function isUser(value: unknown): value is User {
    return typeof value === "object" && 
           value !== null && 
           "name" in value;
}

構造的型システムの罠

TypeScriptは「構造的型システム」を採用しています。これは「同じ構造なら同じ型として扱う」というシステムです。一見便利そうですが、時々罠にはまります。

interface Dog {
    name: string;
    breed: string;
    age: number;
}

interface Car {
    name: string; // 車の名前(例:プリウス)
    breed: string; // 車種(例:セダン)← なぜかbreedという名前w 適切でない名前ですが、とりあえず例として
    age: number; // 年式
}

function petDog(dog: Dog): void {
    console.log(`${dog.name}をなでなでしています`);
}

const myCar: Car = { 
    name: "プリウス", 
    breed: "ハイブリッド", 
    age: 5 
};

petDog(myCar); // エラーにならない!車を撫でることになるw

これ、実際に動いちゃうんです。TypeScriptから見るとDogCarは同じ構造なので「同じ型」として扱われちゃうんですね。

でも実際には犬と車は全然違うものですよね。

Rustは「名目的型システム」なので、このような間違いは起こりません。

struct Dog {
    name: String,
    breed: String,
    age: u32,
}

struct Car {
    name: String,
    breed: String,
    age: u32,
}

fn pet_dog(dog: Dog) {
    println!("{}をなでなでしています", dog.name);
}

let my_car = Car { 
    name: "プリウス".to_string(), 
    breed: "ハイブリッド".to_string(), 
    age: 5 
};

pet_dog(my_car); // コンパイルエラー!「CarはDogじゃないよ!」

Rustでは、たとえ構造が同じでも異なる型として定義されたものは別の型として扱われます。これにより意図しない型の混同を防げるんです。

構造的型システムのメリット

とはいえ、構造的型システムにもメリットはあります。

// ダックタイピング的な柔軟性
interface Flyable {
    fly(): void;
}

class Bird {
    fly() { console.log("鳥が飛んでいます"); }
}

class Airplane {
    fly() { console.log("飛行機が飛んでいます"); }
}

function makeFly(thing: Flyable) {
    thing.fly();
}

makeFly(new Bird()); // OK
makeFly(new Airplane()); // OK - 同じメソッドがあるから

この柔軟性は、動的言語から来た人には馴染みやすいんですよね。でも、時々予期しない動作の原因になることもあります。

部分的な型チェックの落とし穴

TypeScriptでは、オブジェクトが期待される型の一部のプロパティを持っていれば、それで「OK」とされてしまうことがあります。

interface Config {
    host: string;
    port: number;
    ssl: boolean;
    timeout: number;
}

function connect(config: Config) {
    console.log(`${config.host}:${config.port}に接続中...`);
    // timeoutの設定を忘れてる!
}

// 余分なプロパティがあっても通る場合がある
const myConfig = {
    host: "localhost",
    port: 3000,
    ssl: true,
    timeout: 5000,
    debug: true, // 余分だけど、エラーにならないことがある
    logLevel: "info", // これも余分
};

connect(myConfig); // 通っちゃう

また、オブジェクトリテラルの場合とそうでない場合で挙動が違うのも困りものです。

// 直接渡すとエラーになる
connect({
    host: "localhost",
    port: 3000,
    ssl: true,
    timeout: 5000,
    debug: true, // エラー!余分なプロパティ
});

// 変数経由だとエラーにならない
const config = {
    host: "localhost",
    port: 3000,
    ssl: true,
    timeout: 5000,
    debug: true, // エラーにならない
};
connect(config); // 通る

この挙動の違い、最初は理解できません(今でも普通に混乱します)。

より厳密にチェックする方法

もちろん、より厳密にチェックする方法もあります。

// exactプロパティを自作する方法
type Exact<T, U> = T & Record<Exclude<keyof U, keyof T>, never>;

function connectExact<T extends Config>(config: Exact<Config, T>) {
    // より厳密なチェック
}

// または、型アサーションを使う
function connectStrict(config: Config) {
    const exactConfig: Config = {
        host: config.host,
        port: config.port,
        ssl: config.ssl,
        timeout: config.timeout,
    };
    // 必要なプロパティだけを明示的に抽出
}

でも、こういう書き方ってちょっと分かりづらくなるし面倒くさいですよね。

配列の境界チェック問題

TypeScriptは配列の境界チェックを行いません。これ、実はかなり危険です。

const numbers = [1, 2, 3];
console.log(numbers[10]); // undefined が返される(エラーにならない)

const users: User[] = [];
const firstUser = users[0]; // undefinedが返される
console.log(firstUser.name); // 実行時エラー!

JavaScriptの仕様上、配列の範囲外アクセスはundefinedを返すだけなのでTypeScriptもそれに従っています。でも、これがバグの原因になることが多いんです。

Rustでは、配列の境界チェックが厳格に行われます。

let numbers = [1, 2, 3];
println!("{}", numbers[10]); // パニック!プログラムが止まる

// 安全にアクセスしたい場合
match numbers.get(10) {
    Some(value) => println!("値: {}", value),
    None => println!("インデックスが範囲外です"),
}

Rustでは範囲外アクセスは即座にプログラムを停止させます。最初は「厳しすぎる」と思ったんですが、これによりバグが早期発見できるんですね。

TypeScriptでの対策

TypeScriptでも、できるだけ安全に配列にアクセスする方法はあります。

// at()メソッドを使う(ES2022から)
const numbers = [1, 2, 3];
const value = numbers.at(10); // undefinedが返される(明示的)

// 型ガードを使う
function safeArrayAccess<T>(array: T[], index: number): T | undefined {
    return index >= 0 && index < array.length ? array[index] : undefined;
}

// Optional chainingと組み合わせる
const firstUser = users[0];
console.log(firstUser?.name ?? "ユーザーが見つかりません");

でも、こういう安全な書き方を毎回するのは正直面倒ですよね。

ランタイムでの型情報の消失

これはTypeScriptの根本的な問題の一つです。TypeScriptの型情報はコンパイル時に完全に削除されてしまいます。

interface User {
    id: number;
    name: string;
    email: string;
}

function processUser(data: unknown): User {
    // ここで、dataが本当にUserなのかチェックしたいけど...
    // instanceof User とか書けない!
    
    // 仕方ないので型アサーション
    const user = data as User;
    return user; // 本当にUserかどうか分からない
}

// APIから来たデータを処理
const apiResponse = await fetch("/api/user").then(r => r.json());
const user = processUser(apiResponse); // 危険!
console.log(user.name); // もしかしたらエラーになるかも

TypeScriptをJavaScriptにコンパイルすると型情報は全部消えちゃいます。だから実行時に「この値は本当にUser型なのか?」をチェックする方法がないんですね。

ランタイム型チェックの実装

もちろん、自分で型チェック関数を作ることはできます

function isUser(value: unknown): value is User {
    return (
        typeof value === "object" &&
        value !== null &&
        typeof (value as any).id === "number" &&
        typeof (value as any).name === "string" &&
        typeof (value as any).email === "string"
    );
}

function processUserSafe(data: unknown): User | null {
    if (isUser(data)) {
        return data; // 型ガードにより、ここではdataはUser型
    }
    return null;
}

でも、これって面倒くさいですよね...しかも、interfaceが変更されたら型ガード関数も手動で更新しないといけません。

最近はzodなどのライブラリを使ってランタイム型チェックを行うことが多いです。

import { z } from "zod";

const UserSchema = z.object({
    id: z.number(),
    name: z.string(),
    email: z.string(),
});

type User = z.infer<typeof UserSchema>;

function processUserWithZod(data: unknown): User | null {
    const result = UserSchema.safeParse(data);
    return result.success ? result.data : null;
}

これはこれで便利なんですが、型定義を二重で管理しないといけないのがちょっと面倒です。

Union型の不完全なパターンマッチング

TypeScriptのUnion型は便利なんですが、パターンマッチングが不完全です。

type Shape = 
    | { type: "circle"; radius: number }
    | { type: "rectangle"; width: number; height: number }
    | { type: "triangle"; base: number; height: number };

function getArea(shape: Shape) {
    switch (shape.type) {
        case "circle":
            return Math.PI * shape.radius ** 2;
        case "rectangle":
            return shape.width * shape.height;
        // triangleのケースを忘れてる!
        // でもコンパイルエラーにならない
    }
    // ここに到達する可能性があるのに、返り値がない
}

この関数、triangleのケースを処理していないのに、コンパイルエラーになりません。実行時にundefinedが返されちゃいます。

対策方法

もちろん、対策方法はあります。

function getAreaSafe(shape: Shape): number {
    switch (shape.type) {
        case "circle":
            return Math.PI * shape.radius ** 2;
        case "rectangle":
            return shape.width * shape.height;
        case "triangle":
            return (shape.base * shape.height) / 2;
        default:
            // exhaustive check
            const _exhaustive: never = shape;
            throw new Error(`未対応の図形: ${_exhaustive}`);
    }
}

never型を使ったexhaustive checkは有効ですが、毎回書くのは面倒ですよね。

Rustのパターンマッチング

Rustのパターンマッチングは本当に強力です。

enum Shape {
    Circle { radius: f64 },
    Rectangle { width: f64, height: f64 },
    Triangle { base: f64, height: f64 },
}

fn get_area(shape: Shape) -> f64 {
    match shape {
        Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
        Shape::Rectangle { width, height } => width * height,
        // Triangleのケースを書かないと、コンパイルエラー!
    }
}

Rustでは、enumの全てのケースを処理しないとコンパイルエラーになります。新しいケースを追加した時も、既存のmatch文を更新するまでコンパイルが通りません。これにより、パターンの漏れを完全に防げるんです。

型の幅が広すぎる問題

TypeScriptの型は、時々「幅が広すぎる」ことがあります。

// stringは何でも受け入れちゃう
function setUserRole(role: string): void {
    // 本当は "admin" | "user" | "guest" だけを想定してるのに...
}

setUserRole("admin"); // OK
setUserRole("user"); // OK
setUserRole("super-admin-ultra-mega-power"); // これも通る...

// numberも同様
function setAge(age: number): void {
    // 本当は0以上の整数だけを想定してるのに...
}

setAge(25); // OK
setAge(-5); // 負の年齢?
setAge(3.14159); // 小数点の年齢?
setAge(Infinity); // 無限歳?

TypeScriptの基本的な型(stringnumberなど)は、その型に属する全ての値を受け入れてしまいます。

より厳密な型定義

もちろん、より厳密に定義することも可能です。

// Literal型を使う
type UserRole = "admin" | "user" | "guest";

function setUserRole(role: UserRole): void {
    // これで安全
}

// Branded typeを使う
type Age = number & { __brand: "Age" };

function createAge(value: number): Age | null {
    if (value >= 0 && Number.isInteger(value)) {
        return value as Age;
    }
    return null;
}

function setAge(age: Age): void {
    // Ageブランドが付いた値のみ受け入れる
}

でも、こういう厳密な型定義って書くのが面倒だし、既存のコードベースに導入するのも大変です。
でも、データを渡せばAIが最強になる場面

Rustの型システム

Rustでは、より細かい制約を型レベルで表現できます。

// enumを使って限定的な値を表現
enum UserRole {
    Admin,
    User,
    Guest,
}

// newtype patternで制約のある型を作る
struct Age(u8); // 0-255の範囲内

impl Age {
    fn new(value: u8) -> Result<Age, String> {
        if value <= 120 { // 現実的な年齢の上限
            Ok(Age(value))
        } else {
            Err("年齢が無効です".to_string())
        }
    }
}

fn set_age(age: Age) {
    // Ageの制約を満たした値のみ受け入れる
}

Rustでは、「制約のある型」を作るのがTypeScriptより簡単です。

他にもあるかもしれませんが、とりあえずこんなものです。

まとめ

ここまで色々と書いてきましたが、TypeScriptを批判したいわけではないです(TypeScript大好きです)。

TypeScriptがRustほど厳密でない理由は、設計思想の違いにあります

TypeScriptの設計思想

  • JavaScriptとの互換性:既存のJSコードを段階的に移行できる
  • 開発者の生産性:書きやすさを重視
  • エコシステム:既存のJSライブラリをそのまま使える
  • 学習コストの低さ:JSから移行しやすい

Rustの設計思想

  • メモリ安全性:実行時エラーを防ぐ
  • 型安全性:コンパイル時にバグを発見
  • ゼロコスト抽象化:高レベルなコードでもパフォーマンスを犠牲にしない
  • 並行安全性:データ競合を防ぐ

どちらも素晴らしい言語ですが、目指している方向が違うんですね

TypeScriptをより安全に使うコツ

最後に、TypeScriptをより安全に使うためのコツをまとめておきます:

  1. strictモードを有効にするtsconfig.jsonで全ての厳密オプションをオン
{
    "compilerOptions": {
        // 他の設定
        "strict": true
    }
}
  1. anyを避けるunknownや具体的な型を使う
  2. 型アサーションを最小限に:型ガードを使う
  3. Non-null assertionは慎重に:Optional chainingを使う
  4. ランタイム型チェックzodなどのライブラリを活用
  5. ESLintルール@typescript-eslintで危険なパターンを禁止
  6. 型定義の厳密化:Literal型やBranded typeを活用

さいごに

この記事を書いていて思ったのは、プログラミングにおいての型って結構初歩の「し」の字ですが、自分が何もわかっていないということでした。
TypeScriptを少し書けるようになったしRustに手を出してみるか~ってあの型の厳密さ。所有権と借用システムとかも含め、とても難しいけど面白いです。

今回はTypeScriptとRustで比較してみましたが、どちらも学んでおいて損はないと思います。僕もまだまだ勉強中ですが、それぞれの言語の特徴を理解して、Rustに関しては触れはするぐらいまでは勉強しておきたいですね。

でも、Rustより先にTypeScriptを知らなさすぎたので、TypeScriptを極める(?)ことにします。

すこし長くなりましたが、記事を最後まで読んでいただき、ありがとうございました!何か間違いや追加したい内容があれば、コメントで教えてくださいね。

ニート脱却したいな~では。

Discussion