TypeScript製ライブラリと開発体験 TS Meetup #3 TypeScript とわたし 一番最初に触り始めたのは使い始めたのは 2016 年ごろNode.js 製のサーバサイドアプリケーションの運用をしていた Runtime Error に苦しめられる日々 当時の TypeScript は Third Party 製のライブラリが充実しておらず、余計辛かったので使うのを諦める Flow に行ったりしたが、2018 年の春頃から本格的に TypeScript を触り始める TypeScript でツール・ライブラリを作るのが趣味 ライブラリ屋? 今日伝えたいこと TypeScriptの型推論を活用すると、どういったAPIが実現できるのか 実例ベースで型のトリックとともにいくつか紹介します なにか 1 つでもヒントになれば幸いです! まえおき 型パズルがいくつか出てくる こういうこともできるよ、という話 すべての TS のコードでこういったことをしよう、という話ではない TypeScriptでLibraryを書くメリットはなにか Libraryを書く人型に守られるため堅牢性/メンテナンス性/etcが上がる Libraryを使う人(型が正しく提供されていれば)APIの仕様把握のコストが減る IDEの補完による高いDX TSでしかできないようなDXを実現してみたい command line parser catacliの紹介 catacli github TypeScript向けに書いたCommand Line Parser(Node.jsでCLI書くときに使うやつ)commander.js, minimist, yargsなどと同じジャンル 型推論がかなり強く効く 詳しくはこちら (名前変えました) motivation commander.js のAPI(Githubより) const program = require('commander');
program
.option('-d, --debug', 'output extra debugging')
.option('-s, --small', 'small pizza size')
.option('-p, --pizza-type <type>', 'flavour of pizza');
program.parse(process.argv);
if (program.debug) console.log(program.opts());
if (program.small) console.log('- small pizza size');
if (program.pizzaType) console.log(`- ${program.pizzaType}`);
文字列で flag
を定義していくスタイル flagが増えたときや、subcommandとか増えだすと辛い flagを全部型推論させたい!!! 型のトリック ①: String Literalをkeyにしたobjectを返す型 const booleanFlag = makeBooleanFlag("opts1", {
usage: "boolean example"
});
const b = booleanFlag(["--opts"]);
const flags = mergeFlag(booleanFlag, numberFlag, stringFlag);
①: String Literalをkeyにしたobjectを返す型 ユーザが引数に与えた文字列のpropを持ったobjectの型として推論させる // --test <number> のflagを定義したい
const f = makeNumberFlag("test");
// resは { test: number; }として推論される
const res = f(["test", "123"])
Flag(Parser)の型定義(抜粋)type Flag
はコマンドライン引数( process.argv
)を受け取り、parseした結果を返す関数(の型定義) type Flag<T, N extends string> = (args: string[]) => ParseResult<T, N>;
type ParseResult<T, N extends string> = {
[key in N]: {
value?: T;
};
};
実際のFlagParserはこの型を利用して実装されている export type NumberFlag<N extends string> = Flag<number, N>;
export function makeNumberFlag<N extends string>(name: N): NumberFlag<N> {
return (args: string[]) => {
// ~ parserのlogicが入る ~
return {
[name]: { value: parseInt(v, 10) }
};
};
}
ポイントは<N extends string> と[key in N] と型のcapture TypeScriptのextends 特に型パラメータで使われた場合、継承というより、型の制約条件を表すイメージ(参考 ) N extends string
の N
は string
を継承した型というよりも、 string
の型に包含される型という認識のほうが近いstring literal type
(javascriptのstringの値そのものの型)はstringに包含されるtype A<N extends string> = {};
type B = A<"test">; // OK
type C = A<"test1" | "test2">; // OK
type D = A<string>; // OK
type E = A<123>; // NG
mapped types type M<N extends string> = {
[K in N]: string;
}
type B = M<"test">; // { test: string; }として推論される
type C = M<"test1" | "test2">; // { test1: string; test2: string; }として推論される
type D = M<string>; // { [x: string]: string; }として推論される
型のcapture TypeScriptの関数の型パラメータは引数の型をcaptureしてくれる参考 type argument inference
function capture<N>(a: N): N {
// 割愛
}
const t = capture("test") // t = "test" (literal type) として推論される
const t2 = capture<string>("test") // t = string
全部組み合わせるとこうなる 引数をstring literal typeとして推論させることにより、引数で与えた文字列のpropを持ったobjectの型を定義することができる type Flag<T, N extends string> = (args: string[]) => ParseResult<T, N>;
type ParseResult<T, N extends string> = {
[key in N]: {
value?: T;
};
};
export type NumberFlag<N extends string> = Flag<number, N>;
export function makeNumberFlag<N extends string>(name: N): NumberFlag<N> {
return (args: string[]) => {
// 割愛
};
}
②: 可変長引数の型定義 任意のflag parserを組み合わせて、複数のflagをparseできるようにしたい mergeFlag
の型定義についてconst argv = ["--test", "test", "--test2", "test2"]
const f1 = makeStringFlag("test") // (arg: string[]) => ({test: string;});
const f2 = makeStringFlag("test2") // (arg: string[]) => ({test2: string;}):
const merged = mergeFlag(f1, f2) // (args: string[]) => ({test: string; test2: string;});
const res = merged(argv); // {test: string; test2: string; }と推論させたい
引数が2個の場合 任意の関数を2つうけとり、返り値を合成して返す関数 それぞれの関数の返り値を型パラメータで受け取り, intersection type
を用いる function mergeFunction<R1, R2>(f1: (x: any) => R1, f2: (x: any) => R2): (x: any) => R1 & R2 {
// 割愛
}
const f1 = () => ({test: "test"});
const f2 = () => ({test2: "test2"});
const fm = mergeFunction(f1, f2); // (x: any) => ({test: "test"} & {test2: "test2"}) として推論
Flagで書いてみる T1 / T2をそれぞれの引数のFlag(Parser)の型パラメータに渡す function merge2<T1, T1Name extends string, T2, T2Name extends string>(
t1: Flag<T1, T1Name>,
t2: Flag<T2, T2Name>
): Flag<{ [key in T1Name]: T1 } & { [key in T2Name]: T2 }, T1Name | T2Name> {
// 割愛
}
const f1 = makeStringFlag("test")
const f2 = makeStringFlag("test2")
const merged = merge2(f1, f2);
const res = merged(argv) // {test: string; test2: string;} として推論される
引数が3個の場合 function merge3<T1, T1Name extends string, T2, T2Name extends string, T3, T3Name extends string>(
t1: Flag<T1, T1Name>,
t2: Flag<T2, T2Name>,
t3: Flag<T3, T3Name>
): Flag<{ [key in T1Name]: T1 } & { [key in T2Name]: T2 } & { [key in T3Name]: T3 }, T1Name | T2Name | T3Name> {
// 割愛
}
引数がN個の場合 想定される引数の数だけ型定義を書いておく(あるいは生成する)のはひとつのベストプラクティス 本当に任意の数の引数を取るような関数の型定義を正しく書くのは すごく大変 (あとで話します) (参考)yargs api styleの違い method chain likeなapi import * as yargs from 'yargs';
yargs.command('serve', "Start the server.", (argv) => {
/* ここの関数の返り値がそのままhandlerで推論されるようになる */
return argv.option('port', {
describe: "Port to bind on",
default: "5000",
}).option('verbose', {
alias: 'v',
default: false,
});
}, (args) => {
/* argsの */
if (args.verbose) {
console.info("Starting the server...");
}
(args.port);
});
method chainの型定義 関数型チックにやろうとするよりはかなり型は書きやすい N個の引数のときに苦しまなくても良い (あまり使い勝手も変わらない) class FlagParser<T extends object> {
opts: T;
constructor(init: T) {
this.opts = init;
}
addStringFlag<N extends string>(name: N): FlagParser<T & {[key in N]: string;}> {
// 割愛
return <any>this
}
}
const parser = new FlagParser({});
// {test: string; test2: string}として推論される
const o = parser.addStringFlag("test").addStringFlag("test2").opts;
tree shakingとmethod chain (今回はNode.jsのCLIライブラリの話なので関係ない) method chain styleのAPIはtree shakingが効きづらい傾向にあるそれぞれのmethodを独立してexportするのが難しい side effectsの問題 副作用のない関数を組み合わせて機能を作っていくほうがtree shaking的には有利 export function stringFlag(){}
export function numberFlag(){} // numberFlagは使用されていなければbundle時に消える
可変長引数は諦めきれない f(d, f(c, f(a, b)))
f(d, c, b, a);
pipeNで考えてみる // (a: number) => stringに推論されてほしい
const piped = pipeN(
(a: number) => (`${a}`),
(b: string) => ({key: b}),
(c: {key: string}) => (c.key),
);
可変長引数のハンドリング第一歩 可変長引数はtuple として扱える TypeScriptには tuple
という型がある 参考 Arrayとの違いは、長さが固定であること、それぞれの要素の型が固定されていること let x: [string, number];
x = ["hello", 10]; // OK
x = [10, "hello"]; // Error
function<A extends Array<any>> tupleTest(...a: A): A {...}
tupleTest(1, 2, 3, 4, 5) // [number, number, number, number, number]として推論される
Tuple操作のイディオムを覚えよう Tuple操作の型のイディオムは探すと結構出てくる 外部ライブラリを頼るのが良さそう ts-toolbelt にだいたい揃ってる// Tupleの先頭の要素をとってくる
type Head<T extends Array<any>> = ((...args: T) => any) extends (x: infer Head, ...tail: any) => any ? Head : never;
type H = Head<[1, 2, 3, string]> // H = 1
// Tupleの先頭以外をとってくる
type Tail<T extends Array<any>> = ((...args: T) => any) extends (x: any, ...tail: infer Tail) => any ? Tail : never;
type T = Tail<[1, 2, 3, string]> // T = [2, 3, string]
// Tupleの一番最後の要素をとってくる
type Last<T extends any[]> = T[Exclude<keyof T, keyof Tail<T>>];
type L = Last<1, 2, 3, string]> // L = string
あとはくっつける! (conditional typeを使っています) function pipeN<A extends Array<Fn<any, any>>>(...fns: A):
Head<A> extends (a: infer Arg) => any ?
Last<A> extends (a: any) => infer R ? (a: Arg) => R : never : never {
// 実装は割愛
}
const piped = pipeNT(
(a: number) => (`${a}`),
(b: string) => ({test: b})
); // (a: number) => ({test: string}) として推論される
ここまではできた 最高のpipeNを目指して const piped = pipeNT(
(a: number) => (`${a}`), // 返り値の型はstring
(b: string) => ({test: b}) // 注釈なくても引数はstringとして推論してほしい
(c: {test: b}) => 123 // cも推論してほしい...
);
これがまだできていない ramdaのpipe import {pipe} from 'ramda'
const piped = pipe(
(a: number) => (`${a}`), // 返り値の型はstrig
(b) => {
return {test: b} // bはstringとして推論
},
(c) => c.test // cは{test: b}として推論
);
なぜなら
結論 ある程度は頑張れるものの、やっぱり引数が想定される分だけ型定義を用意したほうが推論的にもよさそう ramda / Rxjsはそういうアプローチ まとめ ライブラリを作るときに使ったTypeScriptの型推論のトリックをいくつか紹介しました method chainと関数の合成 可変長引数の取り扱い方