"型パズル"との付き合い方

TypeScript meetup #1
Leko

About me

TypeScript, Node.js, React, React Native
アプリ、ブラウザ、サーバぜんぶTS

言語の中身読んだり AST 解析で遊ぶのが好き

TypeScript歴は1年弱くらい

— TypeScript, CoffeeScript, Flowtype, Node.js - Explore - Google Trends

— The State of JavaScript 2018: JavaScript Flavors - TypeScript

— The State of JavaScript 2018: JavaScript Flavors - TypeScript

本日話すこと

  • "型パズル"とは何か
  • なぜ人は型をパズルしてしまうのか
  • 型パズルのエッセンス
  • 面白かった型パズルのピックアップ
  • 私の型パズルとの向き合い方

まえおき

  • ❌ Compiler API、Language Service
  • ❌ 難解な型パズルを出題して型マウント取る話
  • ❌ 全TSerが型パズルするべきだ
  • 🙆‍♀️ 型パズルと聞いて身構える方に身近に感じてもらいたい

TypeScript and Immutability - TypeScriptでImmutableなエンティティを表現する方法あれこれ

どっちが思考コストが高いか

HTTPメソッド

// ちゃんと渡せばOK
const httpMethod: string = 'GET'
// 正しくない値を渡してもエラーになってくれない
const httpMethod: string = 'hogehoge'
type HTTPMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE' // ...
// ちゃんと渡せばOK
const httpMethod: HTTPMethod = 'GET'
// 正しくない値を渡すと型エラー
const httpMethod: HTTPMethod = 'x'

思考コスト

  • 書き手:この変数(型)に何代入すれば良いんだ?
  • 読み手:この書き方であってるのか?

どっち略?

オブジェクトのゲッター

const obj = {
  a: 'x',
  b: 'y',
  c: 'z',
}
function get(
  obj: { [key: string]: string },
  key: string
) {
  return obj[key]
}
get(obj, 'a') // OK
get(obj, 'x') // NG(no error!!)
const obj = {
  a: 'x',
  b: 'y',
  c: 'z',
}
function get<T extends {}, K extends keyof T>(
  obj: T,
  key: K
) {
  return obj[key]
}
get(obj, 'a') // OK
get(obj, 'x') // NG(error)

Union typeに推論されるメリット

  • 書き手の思考コストを削れる(大文字?小文字? etc)
  • より事実に近い
  • 実行しなくても型レベルで確かめられることが増えている
  • 補完が効く。開発体験がいい。バグが減る

なぜ型をパズルするのか?

  • 日々かかる思考コストを削減したい
  • コードの意図の通りに推論させたい
  • 型で落ちたらバグっている、型が通ればバグってないにしたい
  • 型推論のためだけに書くコードを減らしたい
  • 補完が効くしあわせになりたい

型の門を叩く

これらの公式ドキュメントを読むとイメージが湧くかも

  • Advanced Types · TypeScript
  • TypeScript 2.8 · TypeScript

入門してた頃の私的バイブル

  • TypeScript 2.8 の Conditional Types について - Qiita
  • TypeScript 3.1 の Mapped Tuple Typeについて - Qiita

コスパの悪い型定義

疲弊を招く、費用対が悪い

const user = {
  name: 'Leko',
  age: 27
}
type UserKeys = 'name' | 'age'

手を抜けるところは抜く

型定義と事実が乖離しないようにすると楽

const user = {
  name: 'Leko',
  age: 27
}
type UserKeys = keyof typeof user // 'name | 'age'

手を抜きすぎ

typeofが適切ではないケースはたくさんある

const initialState = {
  fetching: false,
  user: null
}
type State = typeof initialState
// 期待通りに推論されない
export function userReducer(
  state: State,
  action: ...
) {
  switch (action.type) {
    // ...
  }
}
// 意図はこう
type User = { ... }
type State = {
  fetching: boolean,
  user: User | null
}

強気すぎる型

ex. HTTPリクエストのハンドラ
事実と乖離してる。型エラーは起きないが実行時エラー
試す

type UpperEchoPayload = {
  name: string
}
const app = express()
// ここ
app.get('/upper-echo', (req, res) => {
  const { name } = req.body as UpperEchoPayload
  res.send(`Hello, ${name.toUpperCase()}`)
})

as const + ReturnType

reduxのaction creatorとか。
as constがないと推論結果がstringになっちゃう

const addUser = (user: User) => ({
  type: 'user/add' as const,
  user,
})
type AddUserAction = ReturnType<typeof addUser>
//
// いちいちこう書かなくていい
type AddUserAction = {
  type: 'user/add',
  user: User,
}

— 書いて覚える Utility Types

— Advanced built-in types / #kansaits 1 - Speaker Deck

自分では書かないアプローチ

型だけを提供するTSのためのnpmパッケージ登場
TSのbuild-in typeもかなり充実しており、これだけでも足りてることは多い

あくまでユーティリティであり要件を叶える型とは限らない

— sindresorhus/type-fest: A collection of essential TypeScript types
— Announcing TypeScript 3.5 | TypeScript

コスパのいい型定義

  • 型の効用を維持するコストが低い
  • 型検査と実行結果の乖離が少ない

複雑な型を噛み砕く

  • Mapped type
  • Conditional type + infer
  • 再帰型
  • Brand type、幽霊型
  • User defined type guard
  • Overload祭り
  • etc...

全部は時間が足りず話しきれない

Unionが絞れてない

実行時には安全なのだが、型検査上は安全ではない

type VerifiedUser = {
  emailVerified: true,
  profile: UserProfile,
}
type UnverifiedUser = {
  emailVerified: false
}
type User = VerifiedUser | UnverifiedUser
const users: User[] = [...]
// メール確認済みユーザだけなんか処理したい
users
  .filter(user => user.emailVerified)
  .map(user => user.profile)
  // Property 'profile' does not exist on type 'User'.
  //   Property 'profile' does not exist on type 'UnverifiedUser'.

User defined type guard

function isEmailVerifieid (obj: unknown): obj is VerifiedUser {
  return obj.emailVerified
}
const users: User[] = [...]
// やってることは同じ、type guard付けた関数に分けただけ
users
  .filter(isEmailVerifieid)
  .map(user => user.profile) // エラーにならない!

hasなどのチェック用メソッド

const map = new Map<string, number>([...])
// hasでチェックしてるのにgetの結果が | undefinedになっちゃう
if (map.has('a')) {
  const item =  map.get('a')
  item.toFixed() // ERROR
  // Object is possibly 'undefined'.
}

やりようはあるけど

  • thisもtype guardが使えるhas(...): this is ...
  • でもhas捨てる方が楽で型と実行時の乖離が少ないと思う
const map = new Map<string, number>([...])
const item = map.get('x')
if (typeof item !== 'undefined') {
  item.toFixed()
}

Mapped type

カテゴリに対応する文言とアイコン画像

export type Category = 'food' | 'drink'
// ----------------------
import { Category } from './Category'
const categoryNames: { [K in Category]: string } = {
  food: '食べ物',
  drink: '飲み物',
}
// ----------------------
import { Category } from './Category'
import iconFood from './icons/category/food.png'
import iconDrink from './icons/category/drink.png'
const categoryIcons: { [K in Category]: string } = {
  food: iconFood,
  drink: iconDrink,
}

Union + Mapped type

  • 防げる:カテゴリだけ増やして画像や文言定義してない
  • 防げる:文言は足したけど画像が抜けてる(または逆
  • 防げる:キー名であるカテゴリをタイポした
  • カテゴリ名の補完が効く

Replace mapped type with Record

build-inのRecordで書いても似たような結果が得られる

export type Category = 'food' | 'drink'
// ----------------------
import { Category } from './Category'
const categoryNames: Record<Category, string> = {
  food: '食べ物',
  drink: '飲み物',
  xxx: 'hoge', // ERROR(余計なプロパティ)
}
// ----------------------
import { Category } from './Category'
import iconFood from './icons/category/food.png'
import iconDrink from './icons/category/drink.png'
const categoryIcons: Record<Category, string> = {
  food: iconFood,
  // drink: iconDrink, // ERROR(プロパティが足りない)
}

ミニマムから噛み砕いてあとは慣れる

  • 型を強くしたら何が防げるようになるのか?を意識
  • 小さく書いて納得する
  • 最小の例を理解できたら、組み合わさってるものを分解して理解
  • 型書いてる人も小さい状態からだんだん太らせてる(と思う

私の型パズルとの向き合い方

  • 日々かかる思考コストを削減できる型定義をする
  • 型検査と実行時の乖離を減らすために型の知識を増やしてる
  • 型推論のためだけに書くコードを減らす
  • たまにConditional typeたっぷりな型書くが頻出するものではない

    • メンテしんどいぶんのメリットがある型を書く
  • クイズ感覚で解く型パズルは楽しい、解けると脳汁出る

型のハードルを下げたい

  • 型コミッターが増える(@typesや本家のlibなど)
  • 多くの標準API・ライブラリが適切に型付けされる
  • それが高いレベルで維持されやすくなる
  • ユーザ離れを起こしにくくなる
  • TypeScriptが便利になる、育つ、死ににくくなる

※ユーザ人口とコミュニティにフォーカスした場合

Conclusion

  • コスパのいいTypeScript
  • 型に詳しくなるならドキュメントよりリリースノート読むほうが良いと思う

Thanks

@L_e_k_o

References

  • TypeScript 2.8 · TypeScript
  • Advanced Types · TypeScript
  • TypeScript 2.8.1 変更点 - Qiita
  • TypeScript 2.1のkeyofとかMapped typesがアツい - Qiita
  • TypeScript 2.8 の Conditional Types について - Qiita
  • TypeScript 3.1 の Mapped Tuple Typeについて - Qiita
  • TypeScriptで最低n個の要素を持った配列の型を宣言する方法 - Qiita
  • TypeScriptの型入門 - Qiita