.d.tsをどうつくるか
Angularが教えてくれた。
TypeScriptの学びの中で、他のライブラリの型情報をどう手に入れるかは説明されているのだけど、ライブラリとしてどのように型情報を提供するのかはあまり説明されていない。ECMAScriptで書かれたライブラリに後付けでindex.d.tsだけ書かれ、それが@typesに登録されているというのがよくあるケースだけども、そもそもTypeScriptで書いてる場合はどうしたらよいのか。まさか二度手間にチマチマ書きおろすわけもなかろうと調べてるうちに、事例としてとてつもなく巨大なのがあるのを思い出しました。Angularです。たぶんこいつはTypeScriptで書かれた世界最大のソフトウェアなんじゃないかなと思います。実際に、Angularはチマチマindex.d.tsを書くなんてことはしていなく、以下のようなindex.tsを用意しています。
// index.ts export * from './public_api';
このexportしている元を辿って行っても、何段階かは同じようにexport * from XXXというのが徐々に増えながら続いたりもします。そしてこのソースコードを以下のコンパイラオプションで変換する。出力先は私はlibにしましたが、これはお好きなもので結構です。
// tsconfig.json { "compilerOptions": { "outDir": "lib", "declaration": true, } }
declarationスイッチをtrueにすると、全ての.tsファイルから.jsファイルと.d.tsファイルのペアを生成します。index.tsをコンパイルすると、index.jsとindex.d.tsを作ってくれる。そしてこのできたindex.d.tsをpackage.jsonで明らかにすればよい。
// package.json { "main": "./lib/index.js", "types": "./lib/index.d.ts", }
これでライブラリを利用するのがECMAScriptからでもTypeScriptからでも可能になります。
React v16、Enzyme v3
昨日から日本に来てます。
さて、炎上の末にライセンスがMITになったReactのv16がリリースされたと同時期に、Enzymeもv3がリリース。v3からsetupに変化がありました。
Working with React 16.x · Enzyme
説明とおりにしたけど動かねー。CSSセレクターで引っ掛けてたパターンが全滅。@types/enzymeはエラー、さらにはReact本体も警告だしている。
Warning: React depends on requestAnimationFrame. Make sure that you load a polyfill in older browsers. http://fb.me/react-polyfills
React 16 JavaScript Environment.md · GitHub
いろいろ読んで解決しました。
requestAnimationFrameの対処
// package.json部分 "jest": { "moduleFileExtensions": [ "ts", "tsx", "js" ], "setupFiles": [ "raf/polyfill", "./tools/enzyme.setup.js" ], "transform": { "^.+\\.(ts|tsx)$": "./node_modules/ts-jest/preprocessor.js" }, "testMatch": [ "**/__tests__/*((Test|Spec)\\.(ts|tsx))" ] },
yarn add raf -D をしたあとで、package.jsonのjest.setupFilesに、raf/polyfillを追加すると警告は消える。rafはレガシーブラウザでrequestAnimationFrameを埋めるライブラリ。JsDOMがEnzymeの依存で入ってくる古いものだったので、試みに最新に上げてもみましたが、このrequestAnimationFrameは手当しないとダメだった。
@types/enzymeの対処
node_modulesの中の@types/enzymeを見ると、自身の腹にnode_modules/@types/reactを抱え込んでいた。これが悪影響するので抱え込んでるnode_modulesを削除で、問題解決。デプロイのミスかな?
Enzyme v3へのマイグレーション
Migration from 2.x to 3.x · Enzyme
まず説明されてることとして、Jest起動時に走らせるsetupでEnzymeのアダプターなるものを導入する。
// ./tools/enzyme.setup.js // Enzyme v3かつJsDOM v11 var Enzyme = require('enzyme'); var Adapter = require('enzyme-adapter-react-16'); Enzyme.configure({ adapter: new Adapter() }); const { JSDOM } = require('jsdom'); const jsdom = new JSDOM('<!doctype html><html><body></body></html>'); const { window } = jsdom; function copyProps(src, target) { const props = Object.getOwnPropertyNames(src) .filter(prop => typeof target[prop] === 'undefined') .map(prop => Object.getOwnPropertyDescriptor(src, prop)); Object.defineProperties(target, props); } global.window = window; global.document = window.document; global.navigator = { userAgent: 'node.js', }; copyProps(window, global);
「the internal implementation of enzyme has been almost completely rewritten.」とのことですが説明されていたことは私の問題には関係なく、調べて見ると次と同じ現象?
テスト対象のノードを取りやすくするために、id="0"、id="1"、...、id="7"とidに数字を使ってたのですが、form.find('#0')とすると爆発。これはidをid="zero"、id="one"、...、id="seven"と文字で振り直して、form.find('#zero')とすればOK。その後にIDは数字で始まっちゃダメなルールだということをスレッドで教わりました。v2まで通ってたほうがバグでv3で修正されたとの結論。
Reactライクライブラリ
EnzymeのアダプターはReactの各バージョンの挙動の違いに対応するものを整理したと同時に、将来はReactライクライブラリの対応もしたいとのことが書いてありました。PreactやInfernoってこのマイグレーションガイドで初めてしりましたが面白そう。material-uiやreact-routerなどエコシステムのこともあるので近日の現実解ではないけど、JSX変換や基本的なAPIのところをカバーして差し替え可能にするというのはこのEnzymeだけでなく、TypescriptやBabelにもすでにあるので将来無謀なことではないように思います。
TypeScriptでReactのライブラリを作りました
TypeScriptの勉強が嵩じて、一個、Reactのフォーム画面を作るときのめんどくさいところをまとめた小さなライブラリを作りました。
何がめんどくさいと思っていたかというと、
- フォームの一時的状態をどこに持つか悩む。Reduxストアに持つのは大げさじゃないかなあ
- Reactでフォームを便利にする系ライブラリは、UIコンポーネントをがっつり提供することが多い。でもこれやられちゃうとそのライブラリに対応したUIコンポーネントしか使えない。愛するmaterial-uiがつかえないじゃないか、というのも。必要なのはUIコンポーネントのvalueプロパティを設定して、onChangeをフックするだけなのに
- テスト書く時に、書きやすい作りは、Reactのプロパティの仕組みだけでやってるものかな。古いライブラリだとMixin、ちょっと前だとContextを使うのだけど、たぶんHOCでユーティリティをぶっ込んだ方がシンプルになると思った
- Reduxは当然つかう。だからReduxのストアから値をもってきて初期化、UIローカルに値を作ってから、最後にSubmitでDispatchできるようにする
- Submitはもちろん、値のバリデーションも同期だけでなく、非同期対応も必要
この辺、Reactの標準のState & Propertyの仕組みに閉じたアーキテクチャで、非同期はPromiseにだけ対応と決め打ちで作ったらライブラリ本体は結構すぐできました。HOCでやってるのでEnzymeではshallowでレンダリングできず、mountしないとならないのがちょっと美しく無い。
ところで問題は、手元のアプリケーションの中に埋め込みでライブラリとなるクラスおよび関数を作るところではなく、その後ライブラリに切り出すところにありました。以下のトライ&エラーで週末どっぷりでしたよ。
- そもそもNodeモジュールを作ったことがなかった
- TypeScriptの型定義ファイルの作り方がわからなかった -> angularのソースから気がつき解決!
- Githubに公開するのがはじめてなのでドキドキした
- やってみると、gitコマンド操作が怪しい
まだ作りも緩いのでnpmには登録してません。TypeScriptも初めて間も無くなので、まだまだ怪しくうさんくさい。もし試してみてくださる奇特な方がいらっしゃれば、以下のコマンドで。やってませんがes5にコンパイルしてあるのでES5以上で使えるはず。この辺もまだまだ怪しい。
$ yarn add https://github.com/masataka-kurihara/react-form-enhancer.git
今、devブランチでちょいちょい仕上げ作業をしているので、終わってmasterにマージできたらnpm登録もトライしてみたい。
$ yarn add react-form-enhancer
10/20(PT) npmに公開しました。Try it!
in keyof T
このTypeScript2.1で追加されたという keyof演算子をうまくつかうと、オブジェクトのプロパティを縛るのに有用でした。
test ('power of keyof', () => { // 返値のin keyofという受け方が今回の話題。今回Nは捨て型だけど、使ってT[N]とか可能。 function checkObject<T>(props: T): { [N in keyof T]?: string } { return Object.keys(props).reduce( (prior, key) => { // 値をとるためにstringのキー&anyの値セットを持つものとしてasで受ける const value = (props as { [key: string]: any })[key]; if (typeof value === 'number' && value > 0) { return prior; } return { ...prior, [key]: `${key} is invalid.` }; }, {}, ); } type Target = { first: string, second: number, third: number, fourth: boolean, }; const target: Target = { first: '', second: 10, third: -3, fourth: true }; const result = checkObject<Target>(target); expect(result.first).toBe('first is invalid.'); // <- この"first"でIDEの補完が効く! expect(result.second).toBeUndefined(); // 以下、resultのプロパティ名がTargetのそれと同じ // result.fifth; // <- これはコンパイルエラー!素敵。 });
props: Tに対して、props[key]と直接できないのは、便宜上 (props as { [key: string]: any })[key]とas演算で逃げておいて、その後の戻値については、 { [N in keyof T]?: string }なので、プロパティ名は関数呼び出しの時に決めたTのプロパティ名と同じになり、IDEでも補完されるし、もちろん違う名前を使ってるとコンパイルエラー。素敵。
関数オーバーロード
上記URLの最後、関数オーバーロード(記事のOverloads項目)なる言語仕様について。まずは以下のようなのがあったとします。
// .tsxファイル まずはこんなのがあったとします。 import * as React from 'react'; type P = { message: string }; type PP = { greeting: string }; function hoc(Comp: React.ComponentType<Partial<P>>): React.ComponentClass<PP> { return class extends React.Component<PP> { render () { return <Comp message={this.props.greeting} />; } }; }
たった一箇所のJSXタグのために.tsxにするが、それもまたよし。しかしなんか美しくない気がしてReact.createElementを使って書き直したかったけどはじめは理屈がわからなかったので難しかった。言語仕様を勉強してからcreateElementを見直し、簡単に例えると以下のようになっていたことがわかりました。
test('Function Overload', () => { function hoge(arg: number): number; // 後方の緩い関数に型の縛りをつけたエリアス、その1 function hoge(arg: string): string; // 後方の緩い関数に型の縛りをつけたエリアス、その2 function hoge(arg: any): any { // <- 関数の実体。anyで受けてanyで返す。でも型が緩くてイヤ if (typeof arg === 'number') { return arg * arg; } else if (typeof arg === 'string') { return arg + ', Hi!'; } return false; } expect(hoge(5)).toEqual(25); expect(hoge('Masataka')).toBe('Masataka, Hi!'); });
hoge関数はnumberを引数にとるとnumberを、stringを引数にとるとstringを返す関数です。同じようにcreateElementは、StatelessComponentを引数にとるとSFCElement、ComponentClassを引数にとるとCElementを返してました。JavaScriptだとプリミティブ型だけで実装型がないから気にせずなんでもできるけど、TypeScriptではそのままだと型が緩くなってイヤ。なので関数の実体は一つだけど、エリアスをつけて型の縛りだけやっときましょうというもの。
React.ComponentTypeという型が、React.StatelessComponent | React.ComponentClassというユニオン型で、一度引数に渡されてきてこの型になっちゃったら、React.ComponentClassとReact.StatelessComponentのどちらの単体にもそのままでは再キャストすることはできない。
// .tsファイル import { Component, ComponentClass, StatelessComponent, ComponentType, createElement, } from 'react'; type P = { message: string }; type PP = { greeting: string }; function hoc(Comp: ComponentType<Partial<P>>): ComponentClass<PP> { return class extends Component<PP> { render () { const props = { message: this.props.greeting }; // ComponentClassかStatelessComponentのどちらかにas演算 // 関数オーバーロードされているし、実行時はTypeScript型はなくなるのでOK return createElement<P>(Comp as ComponentClass<P>, props); } }; }
as演算は型キャストとちがって型チェックエラーが発生しないので役立ちます。他の実装系での同様機能は型があわない場合はコンパイラエラーだったりnullに変換されたりしますが、TypeScriptは実行時にはECMAScriptにトランスパイルされて実装型がなくなるので余計なことは起きない。ただそこにある値の正しい扱いか間違いなのか関係なくコードが書けるだけ。操作によって実行時エラーが起きるか起きないかは実装内容次第。
- 引数によって戻値内容が変化する関数をTypeScriptで型表現するには関数オーバーロード
- ユニオン型は、その構成要素の互換性のない単体型それぞれにキャストすることができない
- ユニオン型もしくはanyで引数を受けたら、内部ではas演算で型をあわせると辻褄があい、実行時はそもそも無問題
結論
ほかにもユニオン型で書いて怒られても理由がわからず、結局anyに緩くしちゃってたところの解決方法が副作用として理解できました。なるほどユニオン型を構成要素にはキャストできないのか。わかってみるとそりゃそうですね、だからユニオン型で書くのだし。見よう見まねでasをよくわからず書いてたところも、その意味が理解できた。型定義ファイルで大量に同じ名前のものが並んでるのも納得できる。
TypeScriptのユニオン型と関数オーバーロードとas演算子をセットで理解したら、一段上がってかなり目が開けたような気がします。理解できない型チェックエラーが劇的に減りました。
prop-typesか、それに代わる何かが必要な件
これまでReactコンポーネントを書く時、コンポーネントの使われ方を縛るためにprop-typesを用いて動的なチェックをしていました。TypeScriptでは代わって静的な型チェックを利用してprop-typesを使わずとも利用方法を縛るということになってます。しかし、けど漏れる。
import * as React from 'react'; import * as Redux from 'redux'; import * as ReactRedux from 'react-redux'; import * as Enzyme from 'enzyme'; test('HOC prop-types', () => { type Props = { greeting: string }; // greetingは?にしてない必須プロパティ const Target: React.StatelessComponent<Props> = (props) => { return <div className="target">{props.greeting}</div>; }; const Connected = ReactRedux.connect( state => state, dispatch => ({}), )(Target); // モックReducerをjest.fn()で作る const reducer = jest.fn().mockReturnValue({ /* greeting: 'hello' */ }); const store = Redux.createStore(reducer, {}); // HOCのため、Enzymeのmountでフルレンダリング const rendered = Enzyme.mount( <ReactRedux.Provider store={store}> <Connected /> </ReactRedux.Provider>); expect(rendered.find('.target').text()).toBe('hello'); // 上のコメント外さないと不合格 });
上記のJestテストはコンパイルできるけど合格しません。コメントを取り払ってモックReducerがgreetingプロパティを返すようにするとテストが通ります。React.Component
prop-typesも開発ビルド時にコンソールに警告を流してくれるだけですが、それでも静かにすまされるより良い。
また、TypeScriptで書いたモジュールコンポーネントを非TypeScript環境から使う場合には当然ゆるゆる。さてどうしたものでしょうか。prop-typesを二重に書くかと思ったけど、prop-typesの型定義が用意されていないみたい。まったく必要ないと思われているのかな?もしくは賢い、それに代わる何かがあるのか?Javaだとaptでやるような、TypeScriptコンパイラに差し込むプラグインが作れれば型情報から自動でプロテクトコードを挿入したりできるんでしょうけどね。
と、こんなのググったら出てきた。。。結構まどろっこしいな。でもio-tsね。。。
やっぱりあるんですか
Using the Compiler API · Microsoft/TypeScript Wiki · GitHub
あるよ、やっぱりASTとれるか。TypeScriptコンパイラがTypeScriptで書かれているってことだからなあ。
prop-typesを無理やり使えるかな?
この関数を作ったクラスのコンストラクタとかで呼べばいいのじゃないだろうかと思って、以下のように書いたら怒られた。直接呼ぶんじゃなく、PropTypes. checkPropTypes()を呼べという。。。
import * as PropTypes from 'prop-types'; PropTypes.object.isRequired(props, 'yourName', 'TargetComponent'); // -> 警告される。動かない。
https://github.com/facebook/prop-types/blob/master/README.md#proptypescheckproptypes
じゃあやってみようかと思っても、@types/prop-typesの型定義ファイルに、checkPropTypesが無かった。
import * as PropTypes from 'prop-types'; // あるはずのcheckPropTypesの型を定義 declare module 'prop-types' { function checkPropTypes<T>( typeSpecs: PropTypes.ValidationMap<T>, values: T, location: string, componentName: string, getStack?: () => string, ): void; } // TypeScript用のプロパティ定義 type Props = { yourName: string }; // PropTypes用のプロパティ定義 const YourTypes = { yourName: PropTypes.string.isRequired }; // テスト対象のプロパティ const props = { yourName: 'masataka_k' }; PropTypes.checkPropTypes<Props>(YourTypes, props, 'props', 'TargetComponent');
これで完璧!。PropTypes用のプロパティ定義をするのであれば、もっと簡単にECMAScriotで書く時と同様に、static propTypes = YourTypes; とだけで通常運転ですが、動的にチェックするプロパティ定義を組み立てることもできるのでこのテクニックは有用に思います。
Interfaceのメンバ競合の問題
import * as ReduxActions from 'redux-actions'; import * as Redux from 'redux'; // 型の競合エラーがでる interface ExAction extends Redux.Action, ReduxActions.Action<string> {}
現実感の無い実験のためのこのコードは、今日時点では残念ながらコンパイラを通りません。理由はInterfaceのメンバが競合しているからです。
// reduxのindex.d.tsより export interface Action { type: any; // ここでtypeプロパティ宣言 } // @types/redux-actionsのindex.d.tsより export interface BaseAction { type: string; // ここでもtypeプロパティ宣言 } export interface Action<Payload> extends BaseAction { payload?: Payload; error?: boolean; }
どちらもtypeプロパティを持ってるけど、型がreduxのはanyで、@types/redux-actionsはstring。これが双方同じ型かどちらかのtypeプロパティ定義がなければ問題にならない。。reduxの方がanyで個別ならOKだからTypeScriptの将来バージョンでは通るようになるかもしれないけど、今の所はダメ。前記事の「The hidden power of Jest matchers」とメタで似たような問題ながら、TypeScriptのほうでは融通きかない。
調べるきっかけとなった私のコードは次のReduxのMiddlewareファクトリです。
import { Middleware, Action } from 'redux'; import { push } from 'react-router-redux'; type MoveToPayload = { title: string, vol: string, page: number }; export function moveToMiddleware(): Middleware { // redux-actions仕様(=Flux Standard Actions)をDuck Typing interface FSA<Payload> extends Action { payload?: Payload; } // reduxのActionとredux-actionsのAction<Payload>に互換性がないからこじあける return ({ dispatch }) => next => <A extends FSA<MoveToPayload>>(action: A) => { // redux-actionsのAction<Payload>だとnextに渡せない next(action); if (action.type === 'MOVE_TO' && action.payload) { // reduxのActionだとpayloadが受けられない const { payload: { title, vol, page } } = action; dispatch(push(`/book/${title}/${vol}/${page}`)); } return action; }; }
Javaと違ってTypeScriptは型があってもDuck Typingなんで外形が整ってれば同じ型とみなしてくれるから、型をあわせにいきます。型をあわせるためのinterfaceは関数スコープで宣言してみました。通るし、動くけどねえ。こういうものなのかな?まだまだTypeScriptの言語仕様に不案内なので解決方法が怪しい。