Slide 1

Slide 1 text

構造的部分型と serialize 境界 株式会社一休 CTO 室 恩田 崇

Slide 2

Slide 2 text

2 自己紹介 株式会社一休 CTO 室 恩田 崇 1978 年生まれ、京都在住 フルスタック、なんでも屋 一休レストランのフロントエンドアーキテクト Next.js App Router を Remix に書き換えた フロントエンドは IE4/DHTML あたりから

Slide 3

Slide 3 text

3 目次 どんな問題が起きていたのか serialize 問題の解決策 構造的部分型と公称型 型をどう担保するか Q & A プロダクトでのリアルな事例をもとに安全に serialize する方法を考える

Slide 4

Slide 4 text

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}` } ` `

Slide 5

Slide 5 text

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}` } ` ` ` `

Slide 6

Slide 6 text

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' })

Slide 7

Slide 7 text

7 どんな問題が起きていたのか? リスト系 URL のパターンが多い /search /area/** , /ranking/** , /t-scene/** , … etc… 歴史的経緯 一休レストランは2006 年サービス開始 過去の SEO 対策などの経緯 後方互換性の維持 検索条件やデータは変わっている 一度世に出た URL は生き続ける 実際の例 - 背景 ` ` ` ` ` ` ` `

Slide 8

Slide 8 text

8 どんな問題が起きていたのか? URL のパターンに応じた複数の検索条件型 過去の検索条件は現在の検索条件に変換が必要 検索条件は省略可能なフィールドが多い SSR のため TanStack Query で prefetch & dehydrate ( 例外を投げない) 実際の例 - 要因

Slide 9

Slide 9 text

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) }) }

Slide 10

Slide 10 text

10 目次 どんな問題が起きていたのか serialize 問題の解決策 構造的部分型と公称型 型をどう担保するか Q & A プロダクトでのリアルな事例をもとに安全に serialize する方法を考える

Slide 11

Slide 11 text

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}` }

Slide 12

Slide 12 text

12 serialize 問題の解決策 不要なフィールドを落とす - validator library を使う import { z } from 'zod' const schema = z.object({ keyword: z.string(), area: z.string() }) type SearchCriteria = z.infer 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

Slide 13

Slide 13 text

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" }

Slide 14

Slide 14 text

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" }

Slide 15

Slide 15 text

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) }) } ` `

Slide 16

Slide 16 text

16 目次 どんな問題が起きていたのか serialize 問題の解決策 構造的部分型と公称型 型をどう担保するか Q & A プロダクトでのリアルな事例をもとに安全に serialize する方法を考える

Slide 17

Slide 17 text

17 構造的部分型と公称型 型の互換性 = 代入できる、パラメータとして渡せる 公称型 (nominal typing) Java (Scala*, Kotlin) C# (F#*) Rust Swift 構造的部分型 (structural subtyping) TypeScript Go (interface) Scala F# ( 型制約, object expression) 型の互換性をどう実現するか - 二つのアプローチ

Slide 18

Slide 18 text

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(); // コンパイルエラー

Slide 19

Slide 19 text

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'.

Slide 20

Slide 20 text

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 }

Slide 21

Slide 21 text

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

Slide 22

Slide 22 text

22 目次 どんな問題が起きていたのか serialize 問題の解決策 構造的部分型と公称型 型をどう担保するか Q & A プロダクトでのリアルな事例をもとに安全に serialize する方法を考える

Slide 23

Slide 23 text

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 search(SearchCriteria criteria); } // repository.search(oldCriteria) はコンパイルエラー repository.search(oldCriteria.toSearchCriteria());

Slide 24

Slide 24 text

24 型をどう担保するか TypeScript で公称型を使えないか?

Slide 25

Slide 25 text

25 型をどう担保するか private field を持つ class を使う ( 正統) brand hack TypeScript で公称型を実現する

Slide 26

Slide 26 text

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')))

Slide 27

Slide 27 text

27 型をどう担保するか TypeScript で公称型を実現する - brand hack declare const _brand: unique symbol type Brand = T & { [_brand]: B } type Name = Brand type Address = Brand 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"'.

Slide 28

Slide 28 text

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 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" })

Slide 29

Slide 29 text

29 Any Question? 後で気軽に聞いてください!

Slide 30

Slide 30 text

30 エンジニア募集中! 一休では、よりよいサービスを届ける仲間を募集しています。

Slide 31

Slide 31 text

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; } }