Lock in $30 Savings on PRO—Offer Ends Soon! ⏳

TSKaigi Kansai 2024 - 構造的部分型と serialize 境界

ONDA, Takashi
November 16, 2024
1k

TSKaigi Kansai 2024 - 構造的部分型と serialize 境界

ONDA, Takashi

November 16, 2024
Tweet

Transcript

  1. 2 自己紹介 株式会社一休 CTO 室 恩田 崇 1978 年生まれ、京都在住 フルスタック、なんでも屋

    一休レストランのフロントエンドアーキテクト Next.js App Router を Remix に書き換えた フロントエンドは IE4/DHTML あたりから
  2. 3 目次 どんな問題が起きていたのか serialize 問題の解決策 構造的部分型と公称型 型をどう担保するか Q & A

    プロダクトでのリアルな事例をもとに安全に serialize する方法を考える
  3. 4 どんな問題が起きていたのか? /search?keyword=Session-IPA&area=kyoto ← 期待するアウトプット 単純化した例 type SearchCriteria = {

    keyword: string area: string } function toUrl(criteria: SearchCriteria) { const qs = new URLSearchParams(criteria) return `/search?${qs}` } ` `
  4. 5 どんな問題が起きていたのか? /search?keyword=Session-IPA&area=kyoto ← 期待するアウトプット /search?keyword=Session-IPA&area=kyoto&secret=TSKaigi-Kansai ← 意図せず出ちゃう 単純化した例 type

    SearchCriteria = { keyword: string area: string } function toUrl(criteria: SearchCriteria) { const qs = new URLSearchParams(criteria) return `/search?${qs}` } ` ` ` `
  5. 6 どんな問題が起きていたのか? /search?keyword=Session-IPA&area=kyoto&secret=TSKaigi-Kansai ← 意図せず出ちゃう 単純化した例 type SearchCriteria = {

    keyword: string area: string } function toUrl(criteria: SearchCriteria) { const qs = new URLSearchParams(criteria) return `/search?${qs}` } ` ` // 構造的部分型では型チェックが通る正しいコード toUrl({ keyword: 'Session-IPA', area: 'kyoto', secret: 'TSKaigi-Kansai' })
  6. 7 どんな問題が起きていたのか? リスト系 URL のパターンが多い /search /area/** , /ranking/** ,

    /t-scene/** , … etc… 歴史的経緯 一休レストランは2006 年サービス開始 過去の SEO 対策などの経緯 後方互換性の維持 検索条件やデータは変わっている 一度世に出た URL は生き続ける 実際の例 - 背景 ` ` ` ` ` ` ` `
  7. 9 どんな問題が起きていたのか? 実際の例 - コードのイメージ export async function loader({ params,

    request }: LoaderFunctionArgs) { const searchParams = new URL(request.url).searchParams const queryClient = new QueryClient() const oldCriteria: OldSearchCriteria = toSearchCriteria(params, searchParams) // 本来なら現在の検索条件に変換が必要だった // const criteria: SearchCriteria = toCurrentSearchCriteria(oldCriteria) await Promise.all([ // 省略可能なフィールドが多く OldSearchCriteria は SearchCriteria の部分型を満たしていた prefetchInfiniteRestaurants(queryClient, oldCriteria), // prefetch だから例外を投げない... prefetchNeighborhoodsRestaurants(queryClient, oldCriteria), )] return json({ criteria, dehydrateState: hydrate(queryClient) }) }
  8. 10 目次 どんな問題が起きていたのか serialize 問題の解決策 構造的部分型と公称型 型をどう担保するか Q & A

    プロダクトでのリアルな事例をもとに安全に serialize する方法を考える
  9. 11 serialize 問題の解決策 serialize 直前で安全な値に変換する - もっとも基本的な方法 type SearchCriteria =

    { keyword: string area: string } function toUrl(criteria: SearchCriteria) { // criteria から必要な値だけを取りだす const params = { keyword: criteria.keyword, area: criteria.area } const qs = new URLSearchParams(params) return `/search?${qs}` }
  10. 12 serialize 問題の解決策 不要なフィールドを落とす - validator library を使う import {

    z } from 'zod' const schema = z.object({ keyword: z.string(), area: z.string() }) type SearchCriteria = z.infer<typeof schema> function toUrl(criteria: SearchCriteria) { const params = schema.parse(criteria) const qs = new URLSearchParams(params) return `/search?${qs}` } console.log(toUrl({ keyword: 'Session-IPA', area: 'kyoto', secret: 'TSKaigi-Kansai' })) // => /search?keyword=Session-IPA&area=kyoto
  11. 13 serialize 問題の解決策 .passthrough - Zod By default Zod object

    schemas strip out unrecognized keys during parsing. Zod の object スキーマは定義していない key を削除する 不要なフィールドを落とす - Zod const eventSchema = z.object({ name: z.string(), }); eventSchema.parse({ name: "TSKaigi Kansai 2024", where: "Kyoto MIYAKO MESSE", }); // => { name: "TSKaigi Kansai 2024" }
  12. 14 serialize 問題の解決策 object | Valibot This schema removes unknown

    entries. The output will only include the entries you specify. Valibot も同様に定義していない entry を削除する 不要なフィールドを落とす - Valibot import * as v from 'valibot'; const eventSchema = v.object({ name: v.string(), }); v.parse(eventSchema, { name: "TSKaigi Kansai 2024", where: "Kyoto MIYAKO MESSE", }) // => { name: "TSKaigi Kansai 2024" }
  13. 15 serialize 問題の解決策 oldCriteria をそのまま渡せちゃう問題は解決していない。 本来必要な検索条件の変換がされずに、単に認識しないフィールドが落とされるだけ。 より問題に気付きにくくなったとも言える。 prefetch で GraphQL

    の variables に不要な値は含まれなくなったが… export async function loader({ params, request }: LoaderFunctionArgs) { const searchParams = new URL(request.url).searchParams const queryClient = new QueryClient() const oldCriteria: OldSearchCriteria = toSearchCriteria(params, searchParams) // 本来なら現在の検索条件に変換が必要だった // const criteria: SearchCriteria = toCurrentSearchCriteria(oldCriteria) await Promise.all([ // 省略可能なフィールドが多く OldSearchCriteria は SearchCriteria の部分型を満たしていた prefetchInfiniteRestaurants(queryClient, oldCriteria), // prefetch だから例外を投げない... prefetchNeighborhoodsRestaurants(queryClient, oldCriteria), )] return json({ criteria, dehydrateState: hydrate(queryClient) }) } ` `
  14. 16 目次 どんな問題が起きていたのか serialize 問題の解決策 構造的部分型と公称型 型をどう担保するか Q & A

    プロダクトでのリアルな事例をもとに安全に serialize する方法を考える
  15. 17 構造的部分型と公称型 型の互換性 = 代入できる、パラメータとして渡せる 公称型 (nominal typing) Java (Scala*,

    Kotlin) C# (F#*) Rust Swift 構造的部分型 (structural subtyping) TypeScript Go (interface) Scala F# ( 型制約, object expression) 型の互換性をどう実現するか - 二つのアプローチ
  16. 18 構造的部分型と公称型 公称型 (nominal typing) - 継承で型の互換性を明示的に定義 class A {

    String name; } class B extends A { String address; } class A2 { String name; } A b = new B(); // OK A a2 = new A2(); // コンパイルエラー
  17. 19 構造的部分型と公称型 構造的部分型 (structural subtyping) - 型が同じ signature なら互換性をもつ type

    A = { name: string } type B = { name: string } type C = { address: string } const b = { name: 'B' } as const satisfies B const c = { address: 'kyoto' } as const satisfies C const a: A = b // OK const a2: A = c // 型チェックエラー // error: TS2741 [ERROR]: Property 'name' is missing in type '{ readonly address: "kyoto"; }' but required in type 'A'.
  18. 20 構造的部分型と公称型 具体的な型 (struct) が interface のメソッドを提供しているかで判断 構造的部分型 (structural subtyping)

    - Go type Reader interface { Read(p []byte) (n int, err error) } type File struct{} func (f *File) Read(p []byte) (n int, err error) { return 0, nil } func UseReader(r Reader) {} func main() { var f File UseReader(&f) // OK }
  19. 21 構造的部分型と公称型 reflection で実現 構造的部分型 (structural subtyping) - Scala import

    scala.reflect.Selectable.reflectiveSelectable def printName(obj: { def name: String }): Unit = { println(obj.name) } case class Person(name: String) printName(Person("Alice")) // OK
  20. 22 目次 どんな問題が起きていたのか serialize 問題の解決策 構造的部分型と公称型 型をどう担保するか Q & A

    プロダクトでのリアルな事例をもとに安全に serialize する方法を考える
  21. 23 型をどう担保するか 公称型 (nominal typing) なら型変換を強制できる class OldSearchCriteria { String

    q; String area; SearchCriteria toSearchCriteria() { return new SearchCriteria(q, area); } } class SearchCriteria { SearchCriteria(String keyword, String area) { this.keyword = keyword; this.area = area; } String keyword; String area; } interface RestaurantsRepository { List<Restaurant> search(SearchCriteria criteria); } // repository.search(oldCriteria) はコンパイルエラー repository.search(oldCriteria.toSearchCriteria());
  22. 26 型をどう担保するか TypeScript で公称型を実現する - private field を持つ class を使う

    class Animal { #name: string constructor(name: string) { this.#name = name } get name() { return this.#name } } class Robot { readonly #name: string constructor(name: string) { this.#name = name } get name() { return this.#name } } function serialize(a: Animal) { return new URLSearchParams(Object.entries(a)).toString() } // error: TS2345 [ERROR]: Argument of type 'Robot' is not assignable to parameter of type 'Animal'. // Property '#name' in type 'Robot' refers to a different member that cannot be accessed from within type 'Animal'. console.log(serialize(new Robot('name')))
  23. 27 型をどう担保するか TypeScript で公称型を実現する - brand hack declare const _brand:

    unique symbol type Brand<T, B> = T & { [_brand]: B } type Name = Brand<string, "name"> type Address = Brand<string, "address"> const name = "TSKaigi Kansai" as Name const address: Address = name // error: TS2322 [ERROR]: Type 'Name' is not assignable to type 'Address'. // Type 'Name' is not assignable to type '{ [_brand]: "address"; }'. // Types of property '[_brand]' are incompatible. // Type '"name"' is not assignable to type '"address"'.
  24. 28 型をどう担保するか 不要フィールドを落とすだけでなく、型変換も強制できる zod や valibot は brand を提供している import

    * as v from "valibot" const SearchCriteria = v.pipe( v.object({ keyword: v.string(), area: v.string() }), v.brand("SearchCriteria"), ) type SearchCriteria = v.InferOutput<typeof SearchCriteria> const oldCriteria: SearchCriteria = { keyword: "Session-IPA", area: "Kyoto", secret: "TSKaigi Kansai"}; // error: TS2353 [ERROR]: Object literal may only specify known properties, and 'secret' does not exist // in type '{ keyword: string; area: string; } & Brand<"SearchCriteria">'. const criteria: SearchCriteria = v.parse(SearchCriteria, { keyword: "Session-IPA", area: "Kyoto", secret: "TSKaigi Kansai" })
  25. 31 おまけ reflection で property が自動的に serialize されるが annotation で制御するので比較的ミスしにくい

    公称型の言語でも serialize 問題は起きる @JsonInclude(JsonInclude.Include.NON_NULL) // null のフィールドを除外 public class User { @JsonProperty("user_id") // フィールド名をカスタマイズ int id; String name; @JsonIgnore String password; public User(int id, String name, String password) { this.id = id; this.name = name; this.password = password; } }