🐈

o1-preview(o1)がTypeScriptの型を考えてくれるから人生が充実

2024/12/07に公開

この記事は株式会社ガラパゴス(有志)アドベントカレンダー2024の8日目の記事です。

自分は数学がとても苦手なタイプの人間でして、たとえばTypeScriptの型システムの強力さに魅了されつつも、自分では華麗な型パズルを完成させることが出来ないので、偉人たちの残した華麗な型パズルを指をくわえて眺め、その利便性を享受するのみの生活でした。

そこにですね、o1-preview(改めo1)の登場ですよ。

衝撃でした。o1と一緒にTypeScript生活を始めてからは、快適な型生活を送ることが出来ており、人生が非常に充実しております。

本記事では、o1と一緒にに具体的な型定義を作り込んだ事例を紹介したいと思います。

お題: Dify API のレスポンス構造を定義せよ

Difyアドベントカレンダーの記事でも触れたのですが、DifyとSlackを相互接続する中継サービスを開発していた時に、Dify APIの応答を処理する部分で頭を悩ませていました。

具体的には、Difyアプリパラメータ取得APIGET /parametersを呼び出し、レスポンスに記述されたDifyアプリの入力変数の構造を解釈したうえで、入力フォームをSlackのBlock Kitに変換して表現し直す、ということを実装しようとしていました。型でガッツリ固めておかないと後々しんどいパターンですね。。

GET /parametersのレスポンスサンプルを起点にo1-previewで型定義を作り上げた過程を共有します。

1st Try

1st Try: プロンプト

下記のJSONを受信するためのTypeScriptの型定義を考えて下さい。suggested_questionsはstring[]です。

{
  "opening_statement": "",
  "suggested_questions": [],
  "suggested_questions_after_answer": {
    "enabled": false
  },
  "speech_to_text": {
    "enabled": false
  },
  "text_to_speech": {
    "enabled": false,
    "voice": "",
    "language": ""
  },
  "retriever_resource": {
    "enabled": true
  },
  "annotation_reply": {
    "enabled": false
  },
  "more_like_this": {
    "enabled": false
  },
  "user_input_form": [
    {
      "text-input": {
        "variable": "_bridge_slack_channel_id",
        "label": "_bridge_slac_bridge_slack_channel_idk_channel_id",
        "type": "text-input",
        "max_length": null,
        "required": false,
        "options": []
      }
    },
    {
      "text-input": {
        "variable": "name",
        "label": "名前",
        "type": "text-input",
        "max_length": 48,
        "required": true,
        "options": []
      }
    },
    {
      "paragraph": {
        "variable": "profile",
        "label": "プロフィール",
        "type": "paragraph",
        "max_length": null,
        "required": true,
        "options": []
      }
    },
    {
      "select": {
        "variable": "gender",
        "label": "性別",
        "type": "select",
        "max_length": 48,
        "required": true,
        "options": [
          "男性",
          "女性"
        ]
      }
    },
    {
      "number": {
        "variable": "age",
        "label": "年齢",
        "type": "number",
        "max_length": 48,
        "required": true,
        "options": []
      }
    },
    {
      "file": {
        "variable": "icon_image_file",
        "label": "アイコン画像",
        "type": "file",
        "max_length": 48,
        "required": false,
        "options": [],
        "allowed_file_upload_methods": [
          "local_file",
          "remote_url"
        ],
        "allowed_file_types": [
          "video",
          "audio",
          "image",
          "document"
        ],
        "allowed_file_extensions": []
      }
    },
    {
      "file-list": {
        "variable": "other_files",
        "label": "その他ファイル",
        "type": "file-list",
        "max_length": 5,
        "required": false,
        "options": [],
        "allowed_file_upload_methods": [
          "local_file",
          "remote_url"
        ],
        "allowed_file_types": [
          "image",
          "document"
        ],
        "allowed_file_extensions": []
      }
    }
  ],
  "sensitive_word_avoidance": {
    "enabled": false
  },
  "file_upload": {
    "image": {
      "enabled": false,
      "number_limits": 3,
      "transfer_methods": [
        "local_file",
        "remote_url"
      ]
    },
    "enabled": true,
    "allowed_file_types": [
      "image"
    ],
    "allowed_file_extensions": [
      ".JPG",
      ".JPEG",
      ".PNG",
      ".GIF",
      ".WEBP",
      ".SVG"
    ],
    "allowed_file_upload_methods": [
      "local_file",
      "remote_url"
    ],
    "number_limits": 5,
    "fileUploadConfig": {
      "file_size_limit": 15,
      "batch_count_limit": 5,
      "image_file_size_limit": 10,
      "video_file_size_limit": 100,
      "audio_file_size_limit": 50,
      "workflow_file_upload_limit": 10
    }
  },
  "system_parameters": {
    "image_file_size_limit": 10,
    "video_file_size_limit": 100,
    "audio_file_size_limit": 50,
    "file_size_limit": 15,
    "workflow_file_upload_limit": 10
  }
}

1st Try: 出力

以下は、提供されたJSONデータに対応するTypeScriptの型定義です。

interface Config {
  opening_statement: string;
  suggested_questions: string[];
  suggested_questions_after_answer: EnabledFlag;
  speech_to_text: EnabledFlag;
  text_to_speech: TextToSpeech;
  retriever_resource: EnabledFlag;
  annotation_reply: EnabledFlag;
  more_like_this: EnabledFlag;
  user_input_form: UserInputFormItem[];
  sensitive_word_avoidance: EnabledFlag;
  file_upload: FileUpload;
  system_parameters: SystemParameters;
}

interface EnabledFlag {
  enabled: boolean;
}

interface TextToSpeech extends EnabledFlag {
  voice: string;
  language: string;
}

interface FileUpload {
  image: FileUploadImage;
  enabled: boolean;
  allowed_file_types: string[];
  allowed_file_extensions: string[];
  allowed_file_upload_methods: string[];
  number_limits: number;
  fileUploadConfig: FileUploadConfig;
}

interface FileUploadImage {
  enabled: boolean;
  number_limits: number;
  transfer_methods: string[];
}

interface FileUploadConfig {
  file_size_limit: number;
  batch_count_limit: number;
  image_file_size_limit: number;
  video_file_size_limit: number;
  audio_file_size_limit: number;
  workflow_file_upload_limit: number;
}

interface SystemParameters {
  image_file_size_limit: number;
  video_file_size_limit: number;
  audio_file_size_limit: number;
  file_size_limit: number;
  workflow_file_upload_limit: number;
}

interface BaseInputDefinition {
  variable: string;
  label: string;
  type: string;
  max_length: number | null;
  required: boolean;
  options: string[];
}

interface TextInputDefinition extends BaseInputDefinition {
  type: "text-input";
}

interface ParagraphDefinition extends BaseInputDefinition {
  type: "paragraph";
}

interface SelectDefinition extends BaseInputDefinition {
  type: "select";
  options: string[];
}

interface NumberDefinition extends BaseInputDefinition {
  type: "number";
}

interface FileDefinition extends BaseInputDefinition {
  type: "file";
  allowed_file_upload_methods: string[];
  allowed_file_types: string[];
  allowed_file_extensions: string[];
}

interface FileListDefinition extends BaseInputDefinition {
  type: "file-list";
  allowed_file_upload_methods: string[];
  allowed_file_types: string[];
  allowed_file_extensions: string[];
}

type UserInputFormItem =
  | { "text-input": TextInputDefinition }
  | { paragraph: ParagraphDefinition }
  | { select: SelectDefinition }
  | { number: NumberDefinition }
  | { file: FileDefinition }
  | { "file-list": FileListDefinition };

1st Tryの評価

最初の情報量が少ないので当たり前ですが、allowed_file_upload_methodsの定義が微妙だったので、2nd Tryに進みます。

2nd Try

2nd Try: プロンプト

allowed_file_upload_methodsが取る値は、"local_file" または "remote_url" のみです。また、allowed_file_types が取る値は "image", "video", "audio", "document" のみです。それを考慮して再定義をお願いします。

2nd Try: 出力

interface Config {
  opening_statement: string;
  suggested_questions: string[];
  suggested_questions_after_answer: EnabledFlag;
  speech_to_text: EnabledFlag;
  text_to_speech: TextToSpeech;
  retriever_resource: EnabledFlag;
  annotation_reply: EnabledFlag;
  more_like_this: EnabledFlag;
  user_input_form: UserInputFormItem[];
  sensitive_word_avoidance: EnabledFlag;
  file_upload: FileUpload;
  system_parameters: SystemParameters;
}

interface EnabledFlag {
  enabled: boolean;
}

interface TextToSpeech extends EnabledFlag {
  voice: string;
  language: string;
}

type FileUploadMethod = "local_file" | "remote_url";
type FileType = "image" | "video" | "audio" | "document";

interface FileUpload {
  image: FileUploadImage;
  enabled: boolean;
  allowed_file_types: FileType[];
  allowed_file_extensions: string[];
  allowed_file_upload_methods: FileUploadMethod[];
  number_limits: number;
  fileUploadConfig: FileUploadConfig;
}

interface FileUploadImage {
  enabled: boolean;
  number_limits: number;
  transfer_methods: FileUploadMethod[];
}

interface FileUploadConfig {
  file_size_limit: number;
  batch_count_limit: number;
  image_file_size_limit: number;
  video_file_size_limit: number;
  audio_file_size_limit: number;
  workflow_file_upload_limit: number;
}

interface SystemParameters {
  image_file_size_limit: number;
  video_file_size_limit: number;
  audio_file_size_limit: number;
  file_size_limit: number;
  workflow_file_upload_limit: number;
}

interface BaseInputDefinition {
  variable: string;
  label: string;
  type: string;
  max_length: number | null;
  required: boolean;
  options: string[];
}

interface TextInputDefinition extends BaseInputDefinition {
  type: "text-input";
}

interface ParagraphDefinition extends BaseInputDefinition {
  type: "paragraph";
}

interface SelectDefinition extends BaseInputDefinition {
  type: "select";
  options: string[];
}

interface NumberDefinition extends BaseInputDefinition {
  type: "number";
}

interface FileDefinition extends BaseInputDefinition {
  type: "file";
  allowed_file_upload_methods: FileUploadMethod[];
  allowed_file_types: FileType[];
  allowed_file_extensions: string[];
}

interface FileListDefinition extends BaseInputDefinition {
  type: "file-list";
  allowed_file_upload_methods: FileUploadMethod[];
  allowed_file_types: FileType[];
  allowed_file_extensions: string[];
}

type UserInputFormItem =
  | { "text-input": TextInputDefinition }
  | { paragraph: ParagraphDefinition }
  | { select: SelectDefinition }
  | { number: NumberDefinition }
  | { file: FileDefinition }
  | { "file-list": FileListDefinition };

2nd Try: 評価

うん、悪くない。むしろ良い。
user_input_form の変数の中には、サービスとして特別扱いしたい予約変数の存在があることを忘れていたので、要件を追加して次のトライ。

3rd Try

3rd Try: プロンプト

ありがとうございます。バッチリです。最後に、下記の要件を考慮して設計は可能でしょうか?
user_input_formの要素の中で、下記の variable "_bridge_slack_channel_id" の要素は、システム的に特別扱いする予約された要素として認識したいです。また、_bridge_slack_channel_id 以外にも予約変数は増える想定です。

    {
      "text-input": {
        "variable": "_bridge_slack_channel_id",
        "label": "_bridge_slac_bridge_slack_channel_idk_channel_id",
        "type": "text-input",
        "max_length": null,
        "required": false,
        "options": []
      }
    },

3rd Try: 出力

承知しました。ご要望に応じて、user_input_formの要素において、variableが"_bridge_slack_channel_id"の場合にシステム的に特別扱いする予約変数として認識できるように型定義を調整します。また、将来的に予約変数が増えることも考慮します。
以下が更新したTypeScriptの型定義です。

interface Config {
  opening_statement: string;
  suggested_questions: string[];
  suggested_questions_after_answer: EnabledFlag;
  speech_to_text: EnabledFlag;
  text_to_speech: TextToSpeech;
  retriever_resource: EnabledFlag;
  annotation_reply: EnabledFlag;
  more_like_this: EnabledFlag;
  user_input_form: UserInputFormItem[];
  sensitive_word_avoidance: EnabledFlag;
  file_upload: FileUpload;
  system_parameters: SystemParameters;
}

interface EnabledFlag {
  enabled: boolean;
}

interface TextToSpeech extends EnabledFlag {
  voice: string;
  language: string;
}

type FileUploadMethod = "local_file" | "remote_url";
type FileType = "image" | "video" | "audio" | "document";

interface FileUpload {
  image: FileUploadImage;
  enabled: boolean;
  allowed_file_types: FileType[];
  allowed_file_extensions: string[];
  allowed_file_upload_methods: FileUploadMethod[];
  number_limits: number;
  fileUploadConfig: FileUploadConfig;
}

interface FileUploadImage {
  enabled: boolean;
  number_limits: number;
  transfer_methods: FileUploadMethod[];
}

interface FileUploadConfig {
  file_size_limit: number;
  batch_count_limit: number;
  image_file_size_limit: number;
  video_file_size_limit: number;
  audio_file_size_limit: number;
  workflow_file_upload_limit: number;
}

interface SystemParameters {
  image_file_size_limit: number;
  video_file_size_limit: number;
  audio_file_size_limit: number;
  file_size_limit: number;
  workflow_file_upload_limit: number;
}

// 予約変数名を定義します
type ReservedVariableName = "_bridge_slack_channel_id" | "_another_reserved_variable"; // 将来的に増やせます

// 全ての変数名の型を定義します
type VariableName = ReservedVariableName | string;

interface BaseInputDefinition {
  variable: VariableName;
  label: string;
  type: string;
  max_length: number | null;
  required: boolean;
  options: string[];
}

interface TextInputDefinition extends BaseInputDefinition {
  type: "text-input";
}

interface ParagraphDefinition extends BaseInputDefinition {
  type: "paragraph";
}

interface SelectDefinition extends BaseInputDefinition {
  type: "select";
  options: string[];
}

interface NumberDefinition extends BaseInputDefinition {
  type: "number";
}

interface FileDefinition extends BaseInputDefinition {
  type: "file";
  allowed_file_upload_methods: FileUploadMethod[];
  allowed_file_types: FileType[];
  allowed_file_extensions: string[];
}

interface FileListDefinition extends BaseInputDefinition {
  type: "file-list";
  allowed_file_upload_methods: FileUploadMethod[];
  allowed_file_types: FileType[];
  allowed_file_extensions: string[];
}

type UserInputFormItem =
  | { "text-input": TextInputDefinition }
  | { paragraph: ParagraphDefinition }
  | { select: SelectDefinition }
  | { number: NumberDefinition }
  | { file: FileDefinition }
  | { "file-list": FileListDefinition };

例:予約変数のチェック

function handleInputDefinition(input: BaseInputDefinition) {
  if (input.variable === "_bridge_slack_channel_id") {
    // 予約変数に対する特別な処理
  } else {
    // 通常の処理
  }
}

3rd Try: 評価

おうおう、良いではないか。予約変数に対する特別処理のサンプルまで提示してくれて(嬉
この型定義を起点に、その後の処理実装をイメージしてみる。

予約変数は変数名だけでなく変数の型も同時に定まる、という条件を忘れていた。。
ということで次のトライ。

4th Try

4th Try: プロンプト

もう1つ条件がありました。予約変数のvariableとtypeは一体です。たとえば、_bridge_slack_channel_id は type="text-input" で規定されます。

4th Try: 出力

承知しました。ご要望に基づき、予約変数のvariableとtypeが一体となっており、特定のvariableに対して特定のtypeが決まっているように型定義を調整します。また、将来的に予約変数が増える可能性も考慮します。
以下が更新したTypeScriptの型定義です。

interface Config {
  opening_statement: string;
  suggested_questions: string[];
  suggested_questions_after_answer: EnabledFlag;
  speech_to_text: EnabledFlag;
  text_to_speech: TextToSpeech;
  retriever_resource: EnabledFlag;
  annotation_reply: EnabledFlag;
  more_like_this: EnabledFlag;
  user_input_form: UserInputFormItem[];
  sensitive_word_avoidance: EnabledFlag;
  file_upload: FileUpload;
  system_parameters: SystemParameters;
}

interface EnabledFlag {
  enabled: boolean;
}

interface TextToSpeech extends EnabledFlag {
  voice: string;
  language: string;
}

type FileUploadMethod = "local_file" | "remote_url";
type FileType = "image" | "video" | "audio" | "document";

interface FileUpload {
  image: FileUploadImage;
  enabled: boolean;
  allowed_file_types: FileType[];
  allowed_file_extensions: string[];
  allowed_file_upload_methods: FileUploadMethod[];
  number_limits: number;
  fileUploadConfig: FileUploadConfig;
}

interface FileUploadImage {
  enabled: boolean;
  number_limits: number;
  transfer_methods: FileUploadMethod[];
}

interface FileUploadConfig {
  file_size_limit: number;
  batch_count_limit: number;
  image_file_size_limit: number;
  video_file_size_limit: number;
  audio_file_size_limit: number;
  workflow_file_upload_limit: number;
}

interface SystemParameters {
  image_file_size_limit: number;
  video_file_size_limit: number;
  audio_file_size_limit: number;
  file_size_limit: number;
  workflow_file_upload_limit: number;
}

// 予約変数名とそれに対応する型を定義します
type ReservedVariableTypeMap = {
  _bridge_slack_channel_id: "text-input";
  _another_reserved_variable: "number"; // 例として追加
};

type ReservedVariableName = keyof ReservedVariableTypeMap;

// 全ての変数名の型を定義します
type VariableName = ReservedVariableName | string;

// 基本の入力定義
interface BaseInputDefinition {
  variable: VariableName;
  label: string;
  type: string;
  max_length: number | null;
  required: boolean;
  options: string[];
}

// 通常の入力定義
interface TextInputDefinition extends BaseInputDefinition {
  type: "text-input";
  variable: Exclude<VariableName, ReservedVariableName>;
}

interface ParagraphDefinition extends BaseInputDefinition {
  type: "paragraph";
  variable: Exclude<VariableName, ReservedVariableName>;
}

interface SelectDefinition extends BaseInputDefinition {
  type: "select";
  variable: Exclude<VariableName, ReservedVariableName>;
  options: string[];
}

interface NumberDefinition extends BaseInputDefinition {
  type: "number";
  variable: Exclude<VariableName, ReservedVariableName>;
}

interface FileDefinition extends BaseInputDefinition {
  type: "file";
  variable: Exclude<VariableName, ReservedVariableName>;
  allowed_file_upload_methods: FileUploadMethod[];
  allowed_file_types: FileType[];
  allowed_file_extensions: string[];
}

interface FileListDefinition extends BaseInputDefinition {
  type: "file-list";
  variable: Exclude<VariableName, ReservedVariableName>;
  allowed_file_upload_methods: FileUploadMethod[];
  allowed_file_types: FileType[];
  allowed_file_extensions: string[];
}

// 予約変数の入力定義
type ReservedInputDefinition = {
  [K in ReservedVariableName]: BaseInputDefinition & {
    variable: K;
    type: ReservedVariableTypeMap[K];
  };
}[ReservedVariableName];

// UserInputFormItemの型定義
type UserInputFormItem =
  | { "text-input": TextInputDefinition }
  | { paragraph: ParagraphDefinition }
  | { select: SelectDefinition }
  | { number: NumberDefinition }
  | { file: FileDefinition }
  | { "file-list": FileListDefinition }
  // 予約変数の入力定義を追加
  | { [K in ReservedVariableTypeMap[ReservedVariableName]]: Extract<ReservedInputDefinition, { type: K }> };

4th Try: 評価

す、すげえ。3rd Tryまでは頑張れば自分でも定義できそうな型だったけど、ここまで来ると自分の脳では無理ですわ。

特にこのくだり。。

// 予約変数の入力定義
type ReservedInputDefinition = {
  [K in ReservedVariableName]: BaseInputDefinition & {
    variable: K;
    type: ReservedVariableTypeMap[K];
  };
}[ReservedVariableName];

// UserInputFormItemの型定義
type UserInputFormItem =
  | { "text-input": TextInputDefinition }
  | { paragraph: ParagraphDefinition }
  | { select: SelectDefinition }
  | { number: NumberDefinition }
  | { file: FileDefinition }
  | { "file-list": FileListDefinition }
  // 予約変数の入力定義を追加
  | { [K in ReservedVariableTypeMap[ReservedVariableName]]: Extract<ReservedInputDefinition, { type: K }> };

まとめ

という感じで、自分では到底作り込むことの出来なそうな型を作り込んでくれるo1さん、いつもありがとうございます。

株式会社ガラパゴス(有志)

Discussion