🎃

マークダウンからTypeScriptの型を生成してみた

2024/10/31に公開

こんにちは、スペースマーケットでフロントエンドエンジニアをしている8zkです。

マークダウンからTypeScriptの型を生成するscriptを新規プロジェクトで書いたのでそれを説明を交えて紹介したいと思います。

新規プロジェクトでは開発納期やBEの技術選定前にFEの技術選定をしたことなどの兼ね合いで、GraphQLやtRPC,gRPCではなくシンプルなREST APIを採用しました。
それゆえにクライアントで使うAPIの型をどのように生成するかを考えていたのですが、バックエンドエンジニアが先んじてテーブル定義書をマークダウンで書いていたため、それを元に型を生成するscriptを書くことになりました。
後になってわかるのですが、このプロジェクトではsupabaseをAPIとして利用しており、この時はsupabaseが型を生成できることを知りませんでした。https://supabase.com/docs/guides/api/rest/generating-types
(ちなみにschemats(https://github.com/SweetIQ/schemats)やprisma(https://github.com/prisma/prisma)でも同じようなことはできるようなのでそちらを使うのが良いと思います!)

なので結論として、このscriptは現在 "一切使っていません" 😇 けれどちょっとだけ勉強になったのでブログにしました。

使用したライブラリ

今回使用したライブラリは以下になります。

  "devDependencies": {
    "@types/humps": "^2.0.6",
    "@types/node": "^20.14.9",
    "humps": "^2.0.1",
    "markdown-tables-to-json": "^0.1.7",
    "tsx": "^4.16.2",
    "typescript": "^5.5.3"
  }

markdown-tables-to-json

この中で特に注目すべきライブラリはmarkdown-tables-to-json(https://github.com/icooper/markdown-tables-to-json)です。
このライブラリは読んで字の如く、マークダウンからjsonを生成するライブラリです。(内部的には@ts-stack/markdownを利用しているようです。)

サンプルは下記の通りです。

const { Extractor } = require('markdown-tables-to-json');

const md_rows = `
| Name     | Head  | Body  | Tail  | Paws  |
|----------|-------|-------|-------|-------|
| Mittens  | BLACK | black | black | white |
| Dipstick | white | white | black | white |
| Snow     | white | white | white | white |
`;

console.log(Extractor.extractObject(md_rows, 'rows', false));

// output
{
  Mittens: { Head: 'BLACK', Body: 'black', Tail: 'black', Paws: 'white' },
  Dipstick: { Head: 'white', Body: 'white', Tail: 'black', Paws: 'white' },
  Snow: { Head: 'white', Body: 'white', Tail: 'white', Paws: 'white' }
}

このライブラリがあったのでscriptを作ろうと思いました。
ただこのライブラリも大分クセのある作りになっているいてハマりポイントがありました。(後述します)

humps

humps(https://github.com/domchristie/humps)は文字列やobjectのkeyをcamelizeしたり、decamelizeしたり、pascalizeしたりするライブラリです。一般的にはAPIのレスポンスがスネークケースで返ってきた時にcamelizeするのに便利だと感じています。

サンプルは下記の通りです。

const humps = require('humps');

humps.camelize('hello_world-foo bar'); // 'helloWorldFooBar'
humps.pascalize('hello_world-foo bar'); // 'HelloWorldFooBar'
humps.decamelize('helloWorldFooBar'); // 'hello_world_foo_bar'

実装

バックエンドエンジニアが先んじてテーブル定義書をマークダウンで書いていたため

テーブル定義書は以下のようなフォーマットでした。
このテーブルのマークダウンからTypeScriptの型を生成するのがゴールです。

// corps.md
## 内容

契約している法人に関するテーブル

## テーブル情報

| Name    | Field      | Type         | Null | Description              |
| ------- | ---------- | ------------ | ---- | ------------------------ |
| 法人 ID | id         | int(11)      | NO   | Primary key              |
| 法人名  | corp_name  | varchar(255) | NO   |                          |
| 作成日  | created_at | timestamp    | NO   | 自動生成 (DEFAULT now()) |
| 更新日  | updated_at | timestamp    | NO   | 自動生成 (DEFAULT now()) |
| 削除日  | deleted_at | timestamp    | YES  |                          |

コード全体がこちらとなります。

// index.mts
import fs from 'node:fs/promises';
import path from 'node:path';
// This doesn't work because ESM and CommonJS are used at the same time inside the library. so, the ts file is loaded directly.
// markdownTablesToJson from 'markdown-tables-to-json';
import markdownTablesToJson from '../node_modules/markdown-tables-to-json/src/index.ts';
import humps from 'humps';

const { Extractor } = markdownTablesToJson;
const { pascalize, camelize } = humps;

const documentsDir = path.join('../docs', 'tables');
const generatedDir = path.join('../frontend/src/types', 'generated');

const checkNull = (nullValue: string) => {
  if (nullValue === 'YES') {
    return ' | null';
  }
  return '';
};

const mapTypeScriptType = (type: string) => {
  if (
    type.includes('varchar') ||
    type.includes('text') ||
    type.includes('datetime') ||
    type.includes('timestamp')
  ) {
    return 'string';
  }
  if (type.includes('int')) {
    return 'number';
  }
  if (type.includes('boolean')) {
    return 'boolean';
  }
  return '';
};

try {
  await fs.rm(generatedDir, { recursive: true });
  console.info('Removed a generated directory');
} catch {
  console.error('Failed to remove a generated directory');
}

try {
  await fs.mkdir(generatedDir);
  console.info('Created a generated directory');
} catch {
  console.error('Failed to create a generated directory');
}

const filenames = await fs.readdir(documentsDir);

await filenames.reduce(async (pre, filename) => {
  await pre;
  const filenameWithoutExtension = path.parse(filename).name.replace(/.$/, '');
  const typeFilename = filenameWithoutExtension + `.ts`;
  const fileNameWithPath = path.resolve(generatedDir, typeFilename);
  const typeName = pascalize(filenameWithoutExtension);
  const filepath = path.join(documentsDir, filename);
  const content = await fs.readFile(filepath, 'utf8');
  const json: Record<string, { field: string; null: string; type: string }> =
    Extractor.extractObject(content, 'rows', true);
  const output = `export type ${typeName} = {
${Object.entries(json)
  .map(([_, value]) => value)
  .map(
    (item, i) =>
      `${i ? '\n' : ''}  ${camelize(item.field)}: ${mapTypeScriptType(
        item.type
      )}${checkNull(item.null)};`
  )
  .join('')}
}
`;

  try {
    await fs.writeFile(fileNameWithPath, output);
    console.info(`Created a ${typeFilename}`);
  } catch {
    console.error(`Failed to create a ${typeFilename}`);
  }
}, Promise.resolve());

こちらがアウトプットです。
うまくテーブルからTypeScriptの型が生成できているのがわかると思います。(クライアントの都合上keyがcamelizeされています)

// corp.ts
export type Corp = {
  id: number;
  corpName: string;
  createdAt: string;
  updatedAt: string;
  deletedAt: string | null;
}

処理の流れは以下になります。

  • 既に生成されたgeneratedディレクトリを削除
  • generatedディレクトリを生成
  • docs/tablesディレクトリを読み込む(配列でファイル名が返ってくる)
  • ファイル名の配列を直列にループ
    • 対象のファイルを読み込む
    • その内容をmarkdown-tables-to-jsonに渡す(jsonが返ってくる)
    • そのjsonを解析してtypeの文字列を作成
    • TypeScriptファイルを文字列を元に生成

これと言って特別なことはしていませんが、ある程度やりたいことはできたかなと感じています。

ハマったポイント

ただこのライブラリも大分クセのある作りになっているいてハマりポイントがありました。(後述します)

import markdownTablesToJson from 'markdown-tables-to-json';

markdown-tables-to-jsonをimportするとエラーとなりうまく動きません。
markdown-tables-to-jsonのコードを確認してみると、buildされたコードはCommonJSですが、内部でESMを使っています。
そのため、下記のように直接ライブラリの中からbuildする前の.tsファイルを呼び出すことで解決しました。

import markdownTablesToJson from '../node_modules/markdown-tables-to-json/src/index.ts'

importして使えないのはちょっと予想外でした。

まとめ

今回の実装は本運用までいかなかったこともあり、考慮していない部分が多くあります。

  • 英単語の複数形から単数形にするために "最後の1文字を削除してる" だけ(例: corps -> corp)。(本来なら特殊な複数形も含めてしっかりハンドリングすべきだと思います)
  • mapTypeScriptType のロジックの考慮が足りていない。(FLOATやBINARYなど全くハンドリングしてない)

RESTのような型がない世界で型を生成するにはどうしたらよいか考え調べてscriptを書いたのは私にとってはいい経験でした。
読んでいただきありがとうございました。

最後に

スペースマーケットでは一緒に働いてくれる仲間を募集しています!
ちょっと興味がある、少し話を聞いてみたい、といった軽い気持ちでも大丈夫なのでご応募お待ちしています!

スペースマーケット Engineer Blog

Discussion