はじめに
本記事は、JavaエンジニアがTypeScriptを扱う際に感じた違いを解説した記事です。
これまで実務や個人開発でTypeScriptに触れていましたが、Javaの感覚でコードを読み進めていくと、TypeScript特有の概念や挙動に戸惑うことがありました。
今回は個人的に気になった点を7つピックアップして解説します。
TypeScript(JavaScript)よわよわエンジニアなので、誤りあればお手柔らかにお願いします・・!
想定読者
- Javaエンジニア
- JavaScriptは触ったことがあるがTypeScriptは初めて
前提
本記事で扱うTypeScriptとJavaのバージョンです。
- TypeScript : 5.9.3
- Java : 25
1. 実行時(ランタイム)における型情報の有無
TypeScriptは静的型付け言語であり、型注釈を通じて型チェックを行います。
次のコードでは、message変数に文字列型を指定しているため、数値を代入するとエラーが発生します。
let message: string = "Hello, TypeScript!";
message = 42; // 「型 'number' を型 'string' に割り当てることはできません。」というエラーが発生
ただし、TypeScriptにはトランスパイルという概念があり、TypeScriptのコードはJavaScriptに変換されてから実行されます。
この変換の過程で型情報は削除されるため、コードを記述する際の補助として機能しますが、実行(ランタイム)時には影響を与えません。
TypeScriptとJavaにおけるオーバーロードの仕様差異
この「実行時に型情報が存在しない」という性質は、一例として関数のオーバーロード仕様に影響を与えます。
TypeScriptは実行時に型情報が存在するJavaと異なり、実行時に型によって処理を分岐するオーバーロードは実現できません。
void main() {
Calculator calc = new Calculator() {
@Override
public void add(int a, int b) {
IO.println("Integer addition: " + (a + b));
}
@Override
public void add(String a, String b) {
IO.println("String addition: " + (Integer.parseInt(a) + Integer.parseInt(b)));
}
};
calc.add(5, 10); // 実行結果:Integer addition: 15
calc.add("5", "10"); // 実行結果:String addition: 15
}
// オーバーロード:int型とString型のaddメソッドを定義
interface Calculator {
void add(int a, int b);
void add(String a, String b);
}
// 「関数の実装が重複しています。」というエラーが発生
function add (a: number, b: number): void {
console.log("Integer addition: " + (a + b));
}
function add (a: string, b: string): void {
console.log("String addition: " + (Number(a) + Number(b)));
}
add(5, 10)
add("5", "10")
シグネチャのオーバーロードは可能
TypeScriptでは関数の「実装」をオーバーロードすることはできませんが、「シグネチャ」(関数の引数と戻り値の型定義)をオーバーロードすることは可能です。
その場合、関数の本体では引数の型を判定して適切な処理を行う必要があります。
function add(a: number, b: number): void;
function add(a: string, b: string): void;
function add(a: number | string, b: number | string) {
// 型によって処理を分岐
if (typeof a === "number" && typeof b === "number") {
console.log(a + b);
} else if (typeof a === "string" && typeof b === "string") {
console.log(Number(a) + Number(b));
}
}
コンストラクタのオーバーロードについて
先ほど説明したオーバーロードの仕様差異は、コンストラクタのオーバーロードも同様です。
対応の一つとして、先ほど説明したシグネチャのオーバーロードを利用し、実装を1つにまとめる方法があります。
また下記の例のように、コンストラクタの引数に渡していない項目はデフォルト値を設定するケースであれば、デフォルト引数(=)を設定することでオーバーロードのような挙動を実現できます。
void main() {
User user1 = new User("Alice", 30);
User user2 = new User("Bob", 25, "Canada");
IO.println(user1.display()); // Name: Alice, Age: 30, Birth Place: United States
IO.println(user2.display()); // Name: Bob, Age: 25, Birth Place: Canada
}
record User(String name, int age, String birthPlace) {
User(String name, int age) {
this(name, age, "United States");
}
User(String name, int age, String birthPlace) {
this.name = name;
this.age = age;
this.birthPlace = birthPlace;
}
String display() {
return "Name: " + name + ", Age: " + age + ", Birth Place: " + birthPlace;
}
}
class User {
readonly name: string;
readonly age: number;
readonly birthPlace: string;
constructor(
name: string,
age: number,
birthPlace: string = "United States"
) {
this.name = name;
this.age = age;
this.birthPlace = birthPlace;
}
display(): string {
return `Name: ${this.name}, Age: ${this.age}, Birth Place: ${this.birthPlace}`;
}
}
const user1 = new User("Alice", 30);
const user2 = new User("Bob", 25, "Canada");
console.log(user1.display());
console.log(user2.display());
2. 構造的型付けと名前的型付けの違い
プログラム言語における型付けの方法は2種類に大別されます。
- 名前的型付け(Nominal Typing)
- 構造的型付け(Structural Typing)
Javaは名前的型付けを採用しており、型の互換性は名前(クラス名やインターフェース名)によって判断されます。
void main() {
Person person = new Person();
Dog dog = person; // 型の不一致: Main.Person から Main.Dog には変換できません
}
class Person {}
class Dog {}
一方で、TypeScriptは構造的型付けを採用しており、型の互換性はオブジェクトの構造(プロパティやメソッドの形状)によって判断されます。
type Person = { name: string; age: number; };
type Dog = { name: string; age: number; };
let person: Person = { name: "Alice", age: 30 };
let dog: Dog = person; // 問題なし: 構造が一致
サバイバルTypeScriptによると、JavaScriptの
-
ダックタイピング
- オブジェクトの型よりもオブジェクトの持つメソッドやプロパティが何であるかによってオブジェクトを判断するプログラミングスタイル
-
オブジェクトリテラル
-
{}という記法を用いて、オブジェクトを生成する方法
-
という特性がTypeScriptが構造的型付けを採用した背景となっているようです。
脱線しますが、オブジェクトを生成するためにクラスを定義するJavaにとって、インスタンス化せずにオブジェクトを生成できるオブジェクトリテラルはまあまあ見慣れない概念・・。
// プロパティを指定しながらオブジェクトを生成
const person = { name: "Bob", age: 25 };
追記:TypeScriptで名前的型付けを実現する方法
構造的型付けの説明に関して、 @juner さんからコメントを頂きました。
TypeScriptは基本的に構造的型付けですが、名前的型付けを実現する方法あるとのことでした。
下記の通り、privateメンバーを持つクラスの場合は他のクラスと区別されるため、構造が同じでも互換性がなくなり、名前的型付けのように振る舞います。
class Person {
#id: number;
name: string;
age: number;
constructor(id: number, name: string, age: number) {
this.#id = id;
this.name = name;
this.age = age;
}
}
class Dog {
#id: number;
name: string;
age: number;
constructor(id: number, name: string, age: number) {
this.#id = id;
this.name = name;
this.age = age;
}
}
let person: Person = new Person(1, "Alice", 30);
// 型 'Person' を型 'Dog' に割り当てることはできません。
// 型 'Person' のプロパティ '#id' は、型 'Dog' 内からアクセスできない別のメンバーを参照しています。
let dog: Dog = person;
3. interfaceの違い
2. 構造的型付けと名前的型付けの違いで説明した構造的型付けを踏まえて、interfaceの違いについて解説します。
Javaのinterfaceはクラスが実装すべきメソッドのシグネチャを定義します。
implementsしたクラスはinterfaceで定義されたメソッドを実装を強制されます。
今回の例だと、Characterインターフェースを実装するクラスはattackメソッドを実装する必要があります。
interface Character {
void attack();
}
class Mario implements Character {
@Override
public void attack() {
IO.println("ファイアーボール(赤)を実行");
}
}
class Luigi implements Character {
@Override
public void attack() {
IO.println("ファイアーボール(緑)を実行");
}
}
void performAttack(Character character) {
character.attack();
}
void main() {
Character mario = new Mario();
Character luigi = new Luigi();
performAttack(mario); // ファイアーボール(赤)を実行
performAttack(luigi); // ファイアーボール(緑)を実行
}
TypeScriptのinterfaceも同様の実装ができます。
interface Character {
attack(): void;
}
class Mario implements Character {
attack(): void {
console.log("ファイアーボール(赤)を実行");
}
}
class Luigi implements Character {
attack(): void {
console.log("ファイアーボール(緑)を実行");
}
}
function performAttack(character: Character) {
character.attack();
}
const mario = new Mario();
const luigi = new Luigi();
performAttack(mario); // ファイアーボール(赤)を実行
performAttack(luigi); // ファイアーボール(緑)を実行
ただし、TypeScriptでもinterfaceをクラスに実装させることはできますが、interfaceと実装関係がないオブジェクトの型注釈としても利用できます。
また、構造的型付けの性質上、interfaceを明示的に実装していないオブジェクトでも、interfaceの構造に一致すれば型として扱うことができます。
interface Character {
attack(): void;
}
class Mario implements Character {
attack(): void {
console.log("ファイアーボール(赤)を実行");
}
}
-class Luigi implements Character {
- attack(): void {
- console.log("ファイアーボール(緑)を実行");
- }
-}
+const fakeMario: Character = {
+ attack() {
+ console.log("なんちゃってマリオ");
+ }
+};
function performAttack(character: Character) {
character.attack();
}
const mario = new Mario();
-const luigi = new Luigi();
performAttack(mario); // ファイアーボール(赤)を実行
-performAttack(luigi); // ファイアーボール(緑)を実行
+performAttack(fakeMario); // なんちゃってマリオ
TypeScriptのinterfaceとtypeの違い
TypeScriptにはinterfaceに加えてtypeも存在します。
先ほどのコードをtypeで書き換えても同様に動作します。
-interface Character {
- attack(): void;
-}
+type Character = {
+ attack(): void;
+}
class Mario implements Character {
attack(): void {
console.log("ファイアーボール(赤)を実行");
}
}
const fakeMario: Character = {
attack() {
console.log("なんちゃってマリオ");
}
};
function performAttack(character: Character) {
character.attack();
}
const mario = new Mario();
performAttack(mario); // ファイアーボール(赤)を実行
performAttack(fakeMario); // なんちゃってマリオ
interfaceはオブジェクト型を定義するために使用され、typeは任意の型に対して別名を付けるために使用されます。
正直使い分けが良くわかっていなかったのですが、プロを目指す人のためのTypeScript入門 安全なコードの書き方から高度な型の使い方までによると、
ほとんどの場合、interface宣言はtype文で代用可能です。しかもtype文のほうがより多くの場面で使えるので、interface宣言は利用せずにtype文のみ使うという流儀もあるそうです(筆者もそうです)。1
と記述されているので、基本的にはtypeだけで十分なケースも多そうです。
が、自分はあまり詳しくないので参考程度で・・。
ここはもうちょっと勉強します・・。
4. null / undefined の扱い
Javaでnullを扱う場合、NullPointerExceptionを避けるためにOptionalを使用することが一般的です。
下記の例では、Optional<User>を返却するfindUserメソッドがIDに紐づいたUserを見つけることができません。
そのため、User::displayは実行されず、orElseが実行され、User not foundが出力されます。
void main() {
// findUser(3)でUserが見つからないため、orElseが実行される
IO.println(findUser(3).map(User::display).orElse("User not found"));
}
Optional<User> findUser(int id) {
Map<Integer, User> userMap =
Map.ofEntries(Map.entry(1, new User("Alice", 30)), Map.entry(2, new User("Bob", 25)));
return Optional.ofNullable(userMap.get(id));
}
record User(String name, int age) {
String display() {
return "Name: " + name + ", Age: " + age;
}
}
TypeScriptではstrictNullChecksオプションを利用することで、コンパイルの段階でnullやundefinedの扱いを厳密にチェックすることができます。
下記の例では、findUser(3)?.display()のfindUser関数がundefinedを返す可能性があります。
strictNullChecksが有効な場合、undefinedの可能性があるとコンパイル時にエラーが発生します。
オプショナルチェーン演算子(?.)を使用することで、undefinedの場合でもエラーとならず、undefinedのまま処理が進みます。
undefinedの場合、??演算子により"User not found"が出力されます。
function findUser(id: number): User | undefined {
const userMap: Record<number, User> = {
1: {
name: "Alice", age: 30,
display() {
return `Name: ${this.name}, Age: ${this.age}`;
},
},
2: {
name: "Bob", age: 25,
display() {
return `Name: ${this.name}, Age: ${this.age}`;
},
},
};
return userMap[id];
}
type User = {
name: string;
age: number;
display(): string;
};
// strictNullChecks により、オプショナルチェーン演算子(?.)なしではコンパイルエラー
console.log(findUser(3)?.display() ?? "User not found");
オプショナルチェーン演算子(?.)やNull合体演算子(??)を使用しない場合、下記のようにコンパイルエラーが発生します。
+ // 「オブジェクトは 'undefined' である可能性があります。」というエラーが発生
+ console.log(findUser(3).display())
- // strictNullChecks により、存在チェックなしではコンパイルエラー
- console.log(findUser(3)?.display() ?? "User not found");
JavaではOptionalを利用することでNullPointerExceptionを防ぎつつ、すっきりとしたコードを書くことができます。
ただし、意識的にOptionalやnullチェックを行わないと、実行時にNullPointerExceptionが発生する可能性があります。
一方で、TypeScriptではコンパイル時にnullやundefinedの扱いを厳密にチェックできます。
これにより、実行時のエラーを未然に防ぐことができます。
TypeScriptを実装していると'undefined' の可能性があります。のエラーが出ることが多く煩わしく感じることもありますが、実行時エラーを防ぐためには有効な手段だと理解しています。
また、オプショナルチェーン演算子(?.)やNull合体演算子(??)2はJavaエンジニアにとっては見慣れない構文ですが、シンプルで便利な機能だなと思いました。
5. union 型
union型は複数の型を受容する型の定義方法です。
|を用いて複数の型を組み合わせます。
type Result = Success | Failure;
function handle(r: Result) {
if (r.kind === "success") {
r.data; // 型が絞られる
}
}
Javaと違って器用なことができるなと感じます。Javaに置き換えるなら、sealedクラスとrecordの組み合わせが近い?
sealed interface Result permits Success, Failure {}
record Success(String data) implements Result {}
record Failure(String error) implements Result {}
6. switch のパターンマッチング
switch文はTypeScript / JavaScriptにもありますが、switch式のパターンマッチングはTypeScriptには存在しません。
Javaに慣れていると、Javaのswitch式を利用したパターンマッチングは読みやすいと感じます。
type Result = Success | Failure;
type Success = {
kind: "success";
data: string;
};
type Failure = {
kind: "failure";
error: string;
};
function handle(r: Result) {
switch (r.kind) {
case "success":
console.log(r.data);
break;
case "failure":
console.log(r.error);
break;
}
}
handle({ kind: "success", data: "Operation completed successfully." });
void main() {
handle(new Success("Operation completed successfully."));
}
sealed interface Result permits Success, Failure {}
record Success(String data) implements Result {}
record Failure(String error) implements Result {}
void handle(Result r) {
switch (r) {
case Success s -> IO.println(s.data());
case Failure f -> IO.println(f.error());
}
}
7. 例外処理
例外処理について、TypeScriptではtry-catch-finally構文が存在し、これはJavaと同様です。
(非同期処理では.catch()を利用しますが今回は割愛します)
try {
// ...
} catch (e) {
// ...
} finally {
// ...
}
構文はJavaと同様ですが、例外処理に関していくつかの違いがあります。
検査例外・throws宣言が存在しない
Javaには検査例外と呼ばれる、メソッドのシグネチャにthrows宣言が必要な例外があります。
これにより、例外が発生する可能性のあるメソッドを呼び出す際に、
- 例外をtry-catchで処理する
- Throws宣言を追加して呼び出し元に例外を伝播させる
のどちらかの対応をしないと、コンパイルエラーとなります。
void main() {
try {
// Files.readStringは検査例外であるIOExceptionをThrowする可能性があるため、
// try-catchブロックで囲む必要がある
String content = Files.readString(Path.of("sample.txt"));
System.out.println(content);
} catch (IOException e) {
// ファイルが見つからない場合などでIOExceptionが発生した場合の処理
System.err.println("Failed to read file: " + e.getMessage());
}
}
実装段階で例外処理を考慮した実装を強制するのがJavaの特徴ですが、TypeScriptには検査例外やthrows宣言が存在しません。
そのため、TypeScriptでthrowされる例外を意識して適切にtry-catchを実装するには呼び出し先のメソッドを確認する必要があります。
function divide(a: number, b: number): number {
if (b === 0) {
throw new Error("Division by zero is not allowed");
}
return a / b;
}
try {
// divideメソッドが例外をthrowかどうかは呼び出し先を確認する必要がある
// エラーハンドリングしなくてもコンパイルエラーにはならない
console.log(divide(10, 2)); // 5
console.log(divide(10, 0)); // ここで例外
} catch (e) {
if (e instanceof Error) {
console.error(e.message); // Division by zero is not allowed
} else {
console.error("Unknown error", e);
}
}
Error 以外も throw 可能
TypeScript / JavaScriptでは、例外をThrowする場合、Errorオブジェクトを利用するのが一般的です。
ただし、Error以外にも任意の型の値をthrowすることが可能です。
先ほどのコードを下記のように書き換えることもできます。
function divide(a: number, b: number): number {
if (b === 0) {
- throw new Error("Division by zero is not allowed");
+ throw "Division by zero is not allowed";
}
return a / b;
}
try {
console.log(divide(10, 2)); // 5
console.log(divide(10, 0)); // ここで例外
} catch (e) {
if (e instanceof Error) {
console.error(e.message); // Division by zero is not allowed
} else {
console.error("Unknown error", e);
}
}
どういう場面で利用するのだろうか・・。一応アンチパターンのようです。
JavaScriptのthrowはJavaなどと異なり、何でも投げることができます。プリミティブ型でさえ投げれます。
throw "just a string";
これはアンチパターンです。throwが何でも投げられるとしても、Errorオブジェクトを用いるべきです。Errorオブジェクトを使ったほうがコードの読み手に意外性を与えないからです。加えて、スタックトレースが追えるのはErrorオブジェクトだけだからです。3
catch 変数は unknown(型安全)
Javaでは、catchブロックで受け取る例外変数の型はデフォルトでThrowable型となります。
Throwされる例外オブジェクト毎に処理を分岐したい場合は、複数のcatchブロックを用意します。
try {
...
} catch (IOException e) {
...
} catch (SQLException e) {
...
}
一方TypeScriptでは、先ほど述べた通りどんな型の値でもthrowできるため、catchブロックで受け取る例外変数の型はデフォルトでunknown型となります。
unknown型の場合、そのままではプロパティにアクセスできないため、型ガードを用いて型を絞り込む必要があります。
try {
console.log(divide(10, 0)); // ここで例外
} catch (e) {
console.error(e.message); // e''は 'unknown' 型です。
}
型ガードがコンパイラーに強制されるため、JavaScriptよりも型安全に例外処理を実装できます。
Javaのように例外オブジェクト毎に処理を分岐したい場合、catchを複数用意するのではなく、catch内で分岐処理を実装します。
try {
console.log(divide(10, 0)); // ここで例外
} catch (e) {
// instanceof で型を絞り込む
if (e instanceof Error) {
console.error(e.message); // Division by zero is not allowed
} else {
console.error("Unknown error", e);
}
}
おわりに
以上、JavaエンジニアがTypeScriptに触れて違いを感じた7つのポイントを解説しました。
TypeScriptはundefinedや例外処理など、JavaScriptでは実行時エラーとなりそうな部分をコンパイル時にチェックしてくれるのは非常にありがたいと感じました。
一方で、Javaと同様に型を扱っているもののJavaScriptの特性を踏まえた柔軟さがあると理解しました。
Javaエンジニアとして気になる、TypeScript / JavaScript 共通の言語機能
本記事では、JavaScriptにも当てはまる内容はできる限り割愛しています。
ただし、Javaエンジニアにとって見慣れない言語機能がいくつかあるので紹介します。
TypeScript / JavaScript を初めて勉強する際は、以下の内容に触れておくと理解が深まると思います。
-
スプレッド構文
-
{ ...foo };のような書き方でオブジェクトをコピーして、展開できるの便利過ぎない?
-
-
コールバック関数
- TypeScript / JavaScript では基本中の基本と思われるが、Javaだとあまり馴染みがない
- Javaだと関数型インタフェースを用いるが、書き方は冗長で全然別物
-
非同期処理
- 言わずもがな、
Promise/async/await
- 言わずもがな、