Help us understand the problem. What is going on with this article?

[TypeScript 2.4] custom transformer を利用して実行時に型情報を参照可能にする

More than 3 years have passed since last update.

お詫び

当初、 TypeScript 2.3 の機能として紹介していましたが、正しくは 2.4 でした。
誤った情報を掲載してしまい、申し訳ありません。
TypeScript 2.3.1 では、 API として custom transformer を指定できるようにはなっているものの、 bug fix が取り込まれておらず、正常には使えない状態です。

以下、本文

現在の typescript@next では、コンパイル時に custom transformer を指定できるようになり、これによりコンパイラ機能の拡張が可能になりました。

参考: https://github.com/Microsoft/TypeScript/pull/13940

この記事では、この機能を使ってできることの例として、 string literal type の union のメンバーを列挙する関数について紹介します。

成果物

お題

以下のような signature の関数を実現します。

export declare function enumerate<T extends string>(): { [K in T]: K };

こんな風に使えます。

import { enumerate } from 'ts-transformer-enumerate';

type Colors = 'green' | 'yellow' | 'red';
const Colors = enumerate<Colors>();

console.log(Colors.green); // 'green'
console.log(Colors.yellow); // 'yellow'
console.log(Colors.red); // 'red'

これを実現するということは、 type Colors という TypeScript の型情報を、コンパイル時だけでなく、実行時にも参照できるようにする、ということです。
TypeScript に詳しい方ならご存知の通り、これは TypeScript のコンパイラをそのまま使う限り、おそらく将来にわたって不可能なことです。
なぜなら、 TypeScript Design Goals に、 "Non-goals" として以下のように明記されているからです。

5. Add or rely on run-time type information in programs, or emit different code based on the results of the type system. Instead, encourage programming patterns that do not require run-time metadata.

種明かし

上記のコードを、作成した custom transformer を使ってコンパイルすると、以下のような JavaScript が出力されます。

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
var ts_transformer_enumerate_1 = require("ts-transformer-enumerate");
var Colors = { green: "green", yellow: "yellow", red: "red" };
console.log(Colors.green); // 'green'
console.log(Colors.yellow); // 'yellow'
console.log(Colors.red); // 'red'

つまり、 enumerate 関数の呼び出しを、 custom transformer でいい感じに置換してやる、ということです。

custom transformer の実装

詳細に説明するのが面倒なので、直接コードを載せます。
何となく流れを感じ取っていただければ。
AST をたどっていくイメージです。 (AST あまり詳しくないですが、たぶん)

import * as ts from 'typescript';
import * as path from 'path';

export default function transformer(program: ts.Program): ts.TransformerFactory<ts.SourceFile> {
  return (context: ts.TransformationContext) => (file: ts.SourceFile) => visitNodeAndChildren(file, program, context);
}

function visitNodeAndChildren(node: ts.SourceFile, program: ts.Program, context: ts.TransformationContext): ts.SourceFile;
function visitNodeAndChildren(node: ts.Node, program: ts.Program, context: ts.TransformationContext): ts.Node;
function visitNodeAndChildren(node: ts.Node, program: ts.Program, context: ts.TransformationContext): ts.Node {
  return ts.visitEachChild(visitNode(node, program), childNode => visitNodeAndChildren(childNode, program, context), context);
}

function visitNode(node: ts.Node, program: ts.Program): ts.Node {
  const typeChecker = program.getTypeChecker();
  // 置換対象の node かどうか (enumerate 関数の呼び出しかどうか) を判定
  if (!isEnumerateCallExpression(node, typeChecker)) {
    return node;
  }

  // enumerate 関数の呼び出しなら、 typeArguments から型情報を読み取る
  const stringLiteralTypes: string[] = [];
  node.typeArguments && resolveStringLiteralTypes(node.typeArguments[0], typeChecker, stringLiteralTypes);

  // 読み取った型情報をもとに、 object literal を生成して返すと、元の node が置換される
  return ts.createObjectLiteral(stringLiteralTypes.map(stringLiteralType => {
    // unquote string literal type
    const propertyName = stringLiteralType.substring(1, stringLiteralType.length - 1);
    return ts.createPropertyAssignment(propertyName, ts.createLiteral(propertyName));
  }));
}

function isEnumerateCallExpression(node: ts.Node, typeChecker: ts.TypeChecker): node is ts.CallExpression {
  if (node.kind !== ts.SyntaxKind.CallExpression) {
    return false;
  }
  const { declaration } = typeChecker.getResolvedSignature(node as ts.CallExpression);
  return !!declaration
    && (declaration.getSourceFile().fileName === path.resolve(__dirname, '..', 'index.ts'))
    && !!declaration.name
    && (declaration.name.getText() === 'enumerate');
}

function resolveStringLiteralTypes(node: ts.Node, typeChecker: ts.TypeChecker, stringLiteralTypes: string[]): void {
  switch (node.kind) {
    case ts.SyntaxKind.TypeReference:
      const symbol = typeChecker.getSymbolAtLocation((node as ts.TypeReferenceNode).typeName);
      symbol.declarations && symbol.declarations[0].forEachChild(node => resolveStringLiteralTypes(node, typeChecker, stringLiteralTypes));
      break;
    case ts.SyntaxKind.UnionType:
      node.forEachChild(node => resolveStringLiteralTypes(node, typeChecker, stringLiteralTypes));
      break;
    case ts.SyntaxKind.LiteralType:
      const text = (node as ts.LiteralTypeNode).getText();
      stringLiteralTypes.indexOf(text) < 0 && stringLiteralTypes.push(text);
      break;
    default:
      break;
  }
}

公開された API の使い方については現時点で公式のドキュメントがなく、私自身手探りで実装しましたが、きっとそのうち公開されると思うので、正確で詳細な情報については、その時にそちらをご参照いただけると良いと思います。
さわりだけ説明すると、ここでは node の種別(関数呼び出し、型参照等)の判定に node.kind を利用し、型の具体的な情報を知るために、 ts.TypeChecker を利用する、ということをやっています。

custom transformer を利用したコンパイル

非常に残念なことに、 custom transformer を使うためには TypeScript のコンパイルを Node.js の API を利用して行わなければなりません。
以下のような感じです。

const ts = require('typescript');
const enumerateTransformer = require('ts-transfomer-enumerate/transformer').default;

const program = ts.createProgram([/* your files to compile */], {
  strict: true,
  noEmitOnError: true,
  target: ts.ScriptTarget.ES5
});

const transformers = {
  before: [enumerateTransformer(program)],
  after: []
};
const { emitSkipped, diagnostics } = program.emit(undefined, undefined, undefined, false, transformers);

if (emitSkipped) {
  throw new Error(diagnostics.map(diagnostic => diagnostic.messageText).join('\n'));
}

当然ながら、 tsconfig.json とかで指定して普通に tsc で動くようにしてほしい、という要望が上がっており、今後に期待したいところです。
https://github.com/Microsoft/TypeScript/issues/14419

終わりに

実行時に型情報を参照するというのは、 TypeScript を長年(といっても3年くらいですが)愛用してきた身からすると、禁断の果実に手を出してしまう気分です(なので実際のところ濫用は避けたほうがいいかなと思っています)。
「実行時に型情報を参照する」という方向性でも、ここの例以外に色々な用途があるでしょうが、もちろんまったく異なる方向性にも、 custom transformer の使い道はあります。
https://github.com/Microsoft/TypeScript/pull/13940 に貼られている、他の project の issue, PR からのリンクを見ていると、どういうことに custom transformer が使われているか/使われようとしているかが分かって、なかなか興味深いです。
例えば、以下のようなものがあります。

custom transformer が今後どのように使われていくか、それによって TypeScript にどのような変化が起きるか、期待していきたいです。

kimamula
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした