JavaクラスからTypeScriptのインターフェース定義を生成するtypescript-generator Maven/Gradleプラグインを試してみた

フロントエンドをTypeScriptで記述することとなり、リクエストやレスポンスで受け渡すデータもTypeScriptで型定義したいという要望がでてきた。

バックエンドはSpring Bootを使ったJavaアプリケーションで、ダブルメンテになると面倒。

JavaのクラスからTypeScriptの型定義を生成できないかと調べてみたら、 typescript-generator というMaven/Gradleプラグインを使ってできたのでメモ。

github.com

環境

typescript-generator Version 2.24.612。バージョン番号の末尾は公開日の月日かな?

プラグインの導入

Gradleから利用した。Mavenでの設定方法や、各パラメータの設定はGitHubのREADME参照。

apply plugin: 'cz.habarta.typescript-generator'

buildscript {
  repositories {
    mavenCentral()
  }
  dependencies {
    classpath 'cz.habarta.typescript-generator:typescript-generator-gradle-plugin:2.24.612'
  }
}

generateTypeScript {
  jsonLibrary = 'jackson2'
  outputKind = 'global'
}

この状態で gradle generateTypeScript を実行すると、 build/typescript-generator/${project.name}.d.ts が出力される。

パラメーターの設定

出力ファイルの形式など、細かく変更可能。詳細は以下。

TypeScript Generator Maven Plugin – typescript-generator:generate

かなり多いので、確認したものを記述。

必須パラメーター

必須パラメーターは2つ。READMEによると、コンパイルされたClassをJSONとして読み込んで変換しているようで、JSONライブラリが必須。

名前 内容
jsonLibrary JSON変換に使用するライブラリ。
jackson1, jackson2, jaxb, gson, jsonb が設定可能。
outputKind 出力するファイルの内容。
global, module (export...), ambientModule (declare module...) を指定。

Java関連パラメーター

名前 内容
classes 出力対象のクラスを完全修飾クラス名で指定。
ネストされたクラスは className$nestedClassName で指定。
classPatterns 出力対象のクラスをglobで指定。 ** はすべての文字、 *.$ を除く文字に一致。
classesImplementingInterfaces 出力対象のクラスを実装インターフェースで指定。
classesWithAnnotations 出力対象のクラスを付与されたアノテーションで指定。
excludeClasses 除外対象のクラスを完全修飾クラス名で指定。
出力対象のクラスにフィールドとして含まれる他のクラスや、実装しているインターフェースも出力されるため、 java.lang.Comparablejava.io.Serializable を指定しておくといい。

jsonLibraryに 'jackson2' を指定している場合、jackson2Configurationなどで設定を調整できそうだが、未確認。

TypeScript関連パラメーター

名前 内容
outputFileType 出力するファイル種別。 declarationFile (.d.ts), implementationFile (.ts) が設定可能。デフォルトは declarationFile
outputFile 出力するファイル名。デフォルトでは build/typescript-generator/ 配下に、プロジェクト名およびoutputFileTypeに応じた拡張子で出力される。JavaプロジェクトとTypeScriptプロジェクトを同じGitリポジトリで管理している場合、リポジトリ管理下に直接出力するのがよさそう。
module outputKindで ambientModule を指定した場合のモジュール名。
namespace 名前空間名。設定していると namespace が出力される。
Not recommended to combine with modules. とあるが、ここでのmodulesは ambientModule のことかな?
umdNamespace outputFileTypeが declarationFile かつoutputKindで module を指定した場合に設定しておくと、UMDモジュール名となる。
mapClasses デフォルトではJavaのclassもinterfaceも、interfaceとして出力されるが、outputFileTypeが implementationFile の場合、 asClasses を指定すると、Javaのclassはclassとして出力される。
generateConstructors true を指定し、かつmapClassesが asClasses の場合、 constructor(data: <自身の型>) としてコンストラクタが出力される。
mapDate java.util.DateDateマッピングされるが、 asNumberasString を指定することで numberstringマッピングする。
mapEnum java.lang.Enum は列挙型の値を文字列としたUnion型にマッピングされるが、 asInlineUnion, asEnum, asNumberBasedEnum を指定して変更可能。
customTypeMappings javaClassName:typescriptType 形式で、JavaのクラスとTypeScriptの型定義をマッピングする。例えば、 java.time.LocalDateTime:string のような設定が可能。
mapPackagesToNamespaces true を指定すると、Javaのパッケージ名がnamespaceとなる。
addTypeNamePrefix 設定すると、出力される型定義に接頭語が付与される。
addTypeNameSuffix 設定すると、出力される定義に接尾語が付与される。
optionalProperties Optional Propertiesとする方法の指定。デフォルトの useSpecifiedAnnotations では後述のoptionalAnnotationsまたはrequiredAnnotationsで指定。 useLibraryDefinition ではJSONライブラリに応じたアノテーションが付与されたフィールド、 all ではすべてのフィールドとなる。
optionalAnnotations optionalPropertiesが useSpecifiedAnnotations の場合にアノテーションを指定すると、アノテーションが付与されたフィールドはOptional Propertiesとなる。requiredAnnotationsとは排他。
requiredAnnotations optionalPropertiesが useSpecifiedAnnotations の場合にアノテーションを指定すると、アノテーションが付与されていないフィールドはOptional Propertiesとなる。optionalAnnotationsとは排他。
optionalPropertiesDeclaration Optional Propertiesの宣言方法。デフォルトの questionMark では ? が使われる。 questionMarkAndNullableType では ? およびnullとのUnion型となる。 その他、 nullableType , nullableAndUndefinableType , undefinableType が指定可能。
sortDeclarations true を指定すると、出力される型定義やプロパティをアルファベット順でソートされる。プロパティの順序が出力の都度変わることがあったため、気にする場合は true にしておくといい。
sortTypeDeclarations true を指定すると、出力される型定義がアルファベット順でソートされる。こちらはプロパティのソートは行わない。

モジュールおよび名前空間については、以下に詳しい。

Modules and Namespaces

その他にも、 optional から始まるパラメーター、および nullable から始まるパラメーターで、プロパティがオプションか、null許容型かなどを指定できる模様。

出力ファイルフォーマット関連パラメーター

デフォルトでは文字列の囲み文字がダブルクォーテーション、インデントが半角スペース4つ。また、先頭にLintの無効化を含めたコメントが3行出力される。

/* tslint:disable */
/* eslint-disable */
// Generated using typescript-generator version ${typescript-generator.version} on ${yyyy-MM-dd HH:mm:ss}.
名前 内容
stringQuotes 文字列の囲み文字を設定。 doubleQuotes , singleQuotes を設定できる。
indentString インデント文字列を設定。Mavenで設定する場合は属性として xml:space="preserve" を指定。
noTslintDisable true を指定すると、 /* tslint:disable */ を抑制。
noEslintDisable true を指定すると、 /* eslint-disable */ を抑制。
noFileComment true を指定すると、 // Generated... コメントを抑制。

改行コードは設定できない模様。Windowsで出力すると改行コードが CRLF になるので、フォーマットをかけたりがやや面倒。同一GitリポジトリJavaとTypeScriptが保存されている場合などは、生成したらESLintやprettierでフォーマットしておくのがよさそう。

REST Client関連パラメーター

JAX-RSまたはSpringのREST Clinetを生成できる。

Springの場合、pluginの依存性に cz.habarta.typescript-generator:typescript-generator-spring を追加する必要がある。

generateSpringApplicationInterface または generateSpringApplicationClient (outputFileTypeに implementationFile 指定時のみ) に true を指定するとSpring RESTクライアントコードが出力されるということだが、試してみたところ以下が出力された。

// generateSpringApplicationInterface = true の場合
export type RestResponse<R> = Promise<R>;

// generateSpringApplicationClient = true の場合
export interface HttpClient {
  request<R>(requestConfig: { method: string; url: string; queryParams?: any; data?: any; copyFn?: (data: R) => R; }): RestResponse<R>;
}

export type RestResponse<R> = Promise<R>;

function uriEncoding(template: TemplateStringsArray, ...substitutions: any[]): string {
  let result = '';
  for (let i = 0; i < substitutions.length; i++) {
    result += template[i];
    result += encodeURIComponent(substitutions[i]);
  }
  result += template[template.length - 1];
  return result;
}

rest から始まるパラメーター、 restOptionsTyperestResponseType でパラメーターやレスポンスの型を変えられる模様。それぞれ AxiosRequestConfigAxiosPromise を指定した例が記載されているが、試せていない。

JavaとTypeScriptの型マッピング

基本的な型は一通りそろっている。以下のWikiに記載あり。

Type Mapping · vojtechhabarta/typescript-generator Wiki · GitHub

Javaの型 TypeScriptの型
文字列(String, char, Character, UUID) string
数値(java.lang.Number実装クラスおよびそのプリミティブ型) number
論理値(booleanおよびBoolean) boolean
java.util.Date Date, mapDate で変更可能
配列またはjava.util.Collection実装クラス 配列
java.util.Map<String, T>実装クラス 文字列をキーとしたObject
java.lang.Enum 列挙型の値をstringとしたUnion型, mapEnum で変更可能

生成した結果としては、以下のようになった。

  • 指定したクラスが別のクラスを参照している場合、参照先クラスが classPatterns などで指定したクラスに含まれなくとも、定義が出力される。
  • 指定したJSON変換ライブラリの設定内容が反映される。例えば、 java.lang.Enum はデフォルトでは列挙型の値が文字列としてマッピングされるが、Jackson2を使用して、 com.fasterxml.jackson.annotation.JsonValue を用いてフィールドの値をJSONの値としてマッピングした場合、TypeScriptの型定義にもフィールドの値が使用される。

注意点

プリミティブ型もオプションとなる

プリミティブ型も、デフォルトではオプションになる。

issueも上がっているが、調整は現時点ではできない模様。

ラッパークラスで宣言して、 requiredAnnotations で指定したアノテーションを付けておくしかなさそう。

2021/3/22 追記

primitivePropertiesRequired というオプションが追加され、必須化できるようになった。

typescript-generatorにプリミティブ型を必須プロパティ(non null)にするオプションが追加されていた - 毎日へっぽこ

RetentionPolicy.CLASS のアノテーション非対応

JVMに読み込まれたクラスからアノテーションを取得しているため、アノテーションを指定するプロパティでは、基本的に RetentionPolicy.RUNTIME しか判定されない(指定はできるが、無視される)。

Kotlinで記述している場合、こちらのissuevar から付与される @org.jetbrains.annotations.Nullable には対応しているようだが、未確認。

val で付与される @org.jetbrains.annotations.NotNull には対応していない模様。他にも、 @lombok.NonNull も使えなかったりする。

対応としては、別途 @javax.validation.constraints.NotNull あたりを付与しておくか、マーカーアノテーションを独自で用意しておくかしかなさそう。

出力例

build.gradleにて、以下のようなパラメーターを設定。Gitプロジェクトが分かれているため、 finalizedBy で改行コードを変換するついでに、Java側のGitのコミットハッシュをコメントとして追加している。

def tsOutputFileName = "${path/to/output}.ts"

generateTypeScript {
  jsonLibrary = 'jackson2'

  // @Builder で生成されるBuilderクラスを対象外とするため、クラス名の接尾語を指定
  classPatterns = [
      'hepokon365.request.**Request',
      'hepokon365.response.**Response'
  ]
  excludeClasses = [
    'java.lang.Comparable',
    'java.io.Serializable'
  ]
  requiredAnnotations = [
    'javax.validation.constraints.NotNull',
  ]

  outputKind = 'module'
  outputFileType = 'implementationFile'
  outputFile = tsOutputFileName
  namespace = 'Endpoint'
  optionalPropertiesDeclaration = 'questionMarkAndNullableType'

  stringQuotes = 'singleQuotes'
  indentString = '  ' // 半角スペース2つ
  noTslintDisable = true // TSLintは使わない
  noEslintDisable = true // 先頭に追加したいので、出力はせずnormalizeTypeScriptで追加
}

task normalizeTypeScript() doLast {
  def hash = 'git rev-parse --verify HEAD'.execute().text.trim()
  def comment = "/* eslint-disable */\n// Generated ${project.name} git commit hash: ${hash}\n"
  tsOutputFile.text = comment + tsOutputFile.text.normalize()
}

generateTypeScript.finalizedBy normalizeTypeScript

以下のJavaクラス3つを記述。

package hepokon365.request;
@lombok.Data
public class ExampleRequest implements java.io.Serializable {
    @javax.validation.constraints.NotNull
    private String stringValue;
    private boolean booleanValue;
    private hepokon365.enums.ExampleEnum enumValue;
}

package hepokon365.response;
@lombok.Value
@lombok.Builder
public class ExampleResponse implements java.io.Serializable {
    @javax.validation.constraints.NotNull
    private java.math.BigDecimal decimalValue;
    private java.util.Date dateValue;
    private java.util.List<String> stringValues;
}

package hepokon365.enums;
public enum ExampleEnum {
    ENUM_ONE, ENUM_TWO, ENUM_THREE;
}

この状態で gradle generateTypeScript を実行すると、以下のファイルが出力される。 classPatterns で指定されていないクラスも、指定されたクラスから参照されていれば出力に含まれる。

/* eslint-disable */
// Generated ${project.name} git commit hash: ${hash}
// Generated using typescript-generator version 2.24.612 on ${yyyy-MM-dd HH:mm:ss}.

export namespace Endpoint {
  export interface ExampleRequest {
    stringValue: string;
    booleanValue?: boolean | null;
    enumValue?: ExampleEnum | null;
  }

  export interface ExampleResponse {
    decimalValue: number;
    dateValue?: Date | null;
    stringValues?: string[] | null;
  }

  export type ExampleEnum = 'ENUM_ONE' | 'ENUM_TWO' | 'ENUM_THREE';
}

振り返り

細々とフォーマット調整などしているのでパラメーターが多く見えるが、単純に使うだけならクラス指定と出力形式だけでよく、かなり便利。

typescript-generator というプラグイン名が、TypeScriptのジェネレータと被っているので検索に引っ掛かりづらいかも、もったいない。

READMEからリンクされている、以下のブログも参考になる。

www.rainerhahnekamp.com

Spring RESTクライアント生成も、設定がうまくできれば便利そうだが、いったんは型生成で使ってみようと思う。