続 keyof T

in keyof T - まさたか日記

keyof Tを用いて連想配列でアクセスする際の縛りを前に書きましたが、もっとスマートな書き方を思いついたのでメモ。

test ('power of keyof', () => {
    function checkObject<T>(props: T): { [N in keyof T]?: string } {
        return Object.keys(props).reduce(
            (prior, key) => {
                // propsを型変換するのではなく、連想配列キーを「as keyof」でTへ縛る。
                const value = props[key as keyof T];
                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.');
});

変えたのは一箇所で、props[key as keyof T]のとこ。propsを「as { [key: string]: any }」で型変換するのではなく、連想配列キーを「as keyof」でTへ縛る。この応用で、関数の引数とかで、name: stringがあったときに、name: keyof Tというのも有用かと思います。

function foo<T>(obj: T, name: keyof T): any {
    return T[name];
}

コードとしてはあまり意味がないけど、これでキレイに暗黙anyがなくなります。

High Sierraでgit不具合

日本での週末、まだ時差ボケですごく早朝に起きてしまう。

WEB見ててもSierraの時のようには悲鳴があがってない気がしたので、特に検証もすることなくHigh Sierraにアップグレードしました。噂のAPFSなる新ファイルシステムに何のためらいもなく変更されています。見ると構造が物理-コンテナ-ボリュームと層ができていて、未マウントで700MBオーバーで何かがあったり、VMとか表示されているのが見えます。うっすらと互換性に危険な匂いですね。

f:id:masataka_k:20171001060041p:plain

で、一通り普段使いのアプリを動かして見たら、WebStormでエラーが出た。

4:38 Can't start Git: /usr/bin/git
        Probably the path to Git executable is not valid. 

xcrun: error: invalid active developer path (/Library/Developer/CommandLineTools), missing xcrun at: /Library/Developer/CommandLineTools/usr/bin/xcrun.

WebStormのIDE自体は問題なさそうなんだけど、Gitでエラーを出したみたい。直下でxcrunが無いと言われていて、それはcommand line toolsファミリーとしてエラーメッセージからすぐ面の割れるヤツです。同様な事例がMacOSアップグレード時に頻出してましたので、これなら慌てない。

$ xcode-select --install

私は今はフルのXcodeは入れてなくて、Toolsだけ。それをボタンで選んでクリックするとしばらく時間かかりますがToolsの再インストールが完了。そのままWebStormの再起動もなく完治しました。

治ってしばらくしたら、App Storeでもcommand line toolsのアップデートを促してきましたが、アップデート進めてもすでに入れているのでインストーラーが必要ない旨の表示を出してきて終了。いまのところファイルシステム起因等のヤバイヤツは見つけてません。大丈夫っぽい。

.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

昨日から日本に来てます。

facebook.github.io

さて、炎上の末にライセンスが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.」とのことですが説明されていたことは私の問題には関係なく、調べて見ると次と同じ現象?

Unable to parse selectors with numeric values in exponential notation · Issue #1155 · airbnb/enzyme · GitHub

テスト対象のノードを取りやすくするために、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にもすでにあるので将来無謀なことではないように思います。

GitHub - developit/preact: ⚛️ Fast 3kb React alternative with the same ES6 API. Components & Virtual DOM.

GitHub - infernojs/inferno: An extremely fast, React-like JavaScript library for building modern user interfaces

TypeScriptでReactのライブラリを作りました

TypeScriptの勉強が嵩じて、一個、Reactのフォーム画面を作るときのめんどくさいところをまとめた小さなライブラリを作りました。

github.com

何がめんどくさいと思っていたかというと、

  • フォームの一時的状態をどこに持つか悩む。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

TypeScript 2.1 · TypeScript

この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でも補完されるし、もちろん違う名前を使ってるとコンパイルエラー。素敵。

関数オーバーロード

Functions · TypeScript

上記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演算子をセットで理解したら、一段上がってかなり目が開けたような気がします。理解できない型チェックエラーが劇的に減りました。