こぺろロゴ

Coopello Blog

  • Home
  • Techの一覧

フロントエンドエンジニアの友人と“型”で話がすれ違った原因

  • #Android
  • #Kotlin
  • #React Native
  • #TypeScript

※ 本記事は、技術書典で配布している『ゆめみ大技林 '23』に収録されています。他のメンバーの記事もぜひご覧ください!

はじめに

Android アプリエンジニアのどぎーです。
趣味でフロントエンドエンジニアの友人と開発する機会があり、その際に型という言葉の解釈で話が噛み合っていないことに気付きました。
友人が扱っている TypeScript も、私が扱っている Kotlin も、静的型付け言語で型という概念は共通してあるはずです。

ではなぜ型という言葉の解釈で話のすれ違いが生じたのか…

その理由は、TypeScript と Kotlin の型システムが異なっているという点にありました。TypeScript の型システムを構造的部分型、Kotlin の型システムを公称型といいます。
本章では UI の実装例を題材としながら、それらの型システムの違いを学んでいきます。

対象者

  • 普段の開発で公称型の言語(Kotlin など)を扱っていて、これから構造的部分型の言語(TypeScript など)を学ぼうとしている方
  • その逆の方
  • 「はじめに」を読んで少しでも興味をそそられた方

私は TypeScript を学ぶのにすごく苦労しました。
というのも、長い間 TypeScript と Kotlin の型システムが同じものだと思い込んでいたためです。
ただある時、それらの型システムが異なることを知って、急に霧が晴れるような感覚がありました。
私の場合は普段の開発で公称型を扱っていて、構造的部分型を受け入れるのに苦労しましたが、その逆のパターンで苦労している方もいるはずです。
そんな皆さんにとって、本章が少しでも学びの助けになれば幸いです。

それでは早速本題に入っていきましょう!

公称型と構造的部分型

公称型と構造的部分型は、いずれも型システムの種類です。
これらは具体的にどのようなものなので、どのような違いがあるのでしょうか。
次にまとめてみました。

  • 公称型
    • 置換できない
    • 名前によって区別される
    • 厳密さ・安全性
  • 構造的部分型
    • 構造が同じ場合に置換できる
    • 構造によって区別される
    • 柔軟性・拡張性

ここでは「置換できるか・できないか」という違いに着目してみましょう。

公称型(Kotlin)の場合

Kotlin で次の2つのクラスを定義してみます。

class Dog(
  val name: String,
) {
  fun move() {
    // わんわんは動く
  }
}
class Cat(
  val name: String,
) {
  fun move() {
    // にゃんにゃんも動く
  }
}

Dog クラスと Cat クラスはどちらも、name プロパティと move メソッドを持っています。
クラスの構造は同じですが、Kotlin は公称型です。
公称型では、クラスはその名前で区別されるので、Dog クラスと Cat クラスは置換できません。
すなわち次のコードはコンパイルエラーを起こします。
右辺のインスタンスの型が、左辺の変数の型と異なるというエラーです。

val tama: Dog = Cat("tama")
// -> Type mismatch: inferred type is Cat but Dog was expected

val taro: Cat = Dog("taro")
// -> Type mismatch: inferred type is Dog but Cat was expected

構造的部分型(TypeScript)の場合

TypeScript でも Dog クラスと Cat クラスを定義してみます。

class Dog {
  name: string;
 
  constructor(name: string) {
    this.name = name;
  }

  move() {
    // わんわんは動く
  }
}
class Cat {
  name: string;
 
  constructor(name: string) {
    this.name = name;
  }

  move() {
    // にゃんにゃんも動く
  }
}

構造的部分型においては、クラスはその構造によって区別されます。
Dog クラスと Cat クラスはどちらも name プロパティと move メソッドを持っており、同じ構造です。
公称型の言語に慣れ親しんでいる私にとっては大変驚きですが、TypeScript では次のコードが実際に動きます。
Dog 型の変数に Cat 型のインスタンスが代入できてしまうのです。

const dog: Dog = new Cat("tama")
console.log(dog.name)
// => "tama"

以上の解説で、同じ静的型付け言語でも異なる2つの型システムが存在することを学びました。
次節からは UI の実装例を題材としながら、
Android アプリエンジニアの私とフロントエンドエンジニアの友人の話がどういった点で食い違ってしまったのかを紹介していきます。

UI の実装

本章で実装する UI の要件は次のとおりです。

  • 3種類のコンポーネントをリスト表示する
    • 横長のリストアイテム ListItem
    • 大きい正方形のリストアイテム LargeListItem
    • 複数カラムのリストアイテム MultiColumnListItem
  • 各コンポーネントの個数に制限はない
  • コンポーネントの順番は好きなように入れ替えられる

実際に実装したスクショは次のとおりです。

Jetpack Compose

React Native

次節からは実際に UI を実装していきます。
ただしページ数の制約のため、掲載しているサンプルコードの一部を簡略化しています。
そのままでは動かないコードもあります。
章末に掲載した GitHub のリポジトリにサンプルコードの全体を載せてありますので、興味のある方はぜひそちらをご確認ください!

Jetpack Compose(Kotlin)での実装

Jetpack Compose でリスト表示を実現するには Lazy リストを使用します。
今回は垂直方向のリストのため LazyColumn を使用します。
LazyColumn の実装例は次のとおりです。

// 出典:https://developer.android.com/jetpack/compose/lists

import androidx.compose.foundation.lazy.items

@Composable
fun MessageList(messages: List<Message>) {
    LazyColumn {
        items(messages) { message ->
            MessageRow(message)
        }
    }
}

LazyColumn のブロックで items メソッドを実行しています。
items メソッドの items という引数にリストを渡し、items メソッドのブロックの中で1つずつ取り出すことができます。
今回の要件では、3種類のリストアイテムを好きな個数、好きな順番で並べることが求められています。
items に渡せる型は1種類なので、どうにか工夫して3種類のリストアイテムを表現する必要があります。
こんなときに便利なのが Kotlin の sealed class です。
sealed class で3種類のリストアイテムを表現すると次のようになります。

sealed class HomeScreenUiModel {

  // 横長のリストアイテム
  data class ListItemUiModel(
    val text: String,
  ) : HomeScreenUiModel()

  // 大きい正方形のリストアイテム
  data class LargeListItemUiModel(
    val text: String,
  ) : HomeScreenUiModel()

  // 複数カラムのリストアイテム
  data class MultiColumnListItemUiModel(
    val texts: List<String>,
  ) : HomeScreenUiModel()
}

ListItemUiModel, LargeListItemUiModel, MultiColumnListItemUiModel はいずれも HomeScreenUiModel
を継承しているので、HomeScreenUiModel として扱うことができます。
また sealed というキーワードを付けているので、これら3種類のクラス以外は HomeScreenUiModel を継承できません。
その結果、HomeScreenUiModel をそれら3種類の型で網羅的に条件分岐できます。
実際に HomeScreenUiModel を型で条件分岐し、LazyColumn で表示するコードは次のようになります。

@Composable
fun HomeScreen(uiModels: List<HomeScreenUiModel>) {
  LazyColumn {
    items(items = uiModels) { uiModel ->
      when (uiModel) {
        is HomeScreenUiModel.ListItemUiModel -> ListItem(text = uiModel.text)
        is HomeScreenUiModel.LargeListItemUiModel -> LargeListItem(text = uiModel.text)
        is HomeScreenUiModel.MultiColumnListItemUiModel -> MultiColumnListItem(texts = uiModel.texts)
      }
    }
  }
}

以上で、要件を満たす UI を実装できました!

React Native(TypeScript)での実装

React Native でリスト表示を実現するには FlatList を使用します。
FlatList の実装例は次のとおりです。

// 出典:https://reactnative.dev/docs/flatlist

const App = () => {
  return (
    <SafeAreaView style={styles.container}>
      <FlatList
        data={DATA}
        renderItem={({item}) => <Item title={item.title} />}
        keyExtractor={item => item.id}
      />
    </SafeAreaView>
  );
};

data という Props に表示したい内容のリストを渡し、renderItem に渡したコンポーネントで1つずつ表示していきます。
data に渡せる型は1種類なので、Jetpack Compose の場合と同様に、どうにか工夫して3種類のリストアイテムを表現する必要があります。
Kotlin ではクラスを使用しましたが、TypeScript ではユニオン型を使用します。
ユニオン型は型をバーティカルバー(|)で連結し、その連結した型のうちのどれかを表します。
ユニオン型で HomeScreenUiModel を表現すると次のようになります。

// 横長のリストアイテム
type ListItemUiModel = {
  type: 'item';
  text: string;
};

// 大きい正方形のリストアイテム
type LargeListItemUiModel = {
  type: 'large';
  text: string;
};

// 複数カラムのリストアイテム
type MultiColumnListItemUiModel = {
  type: 'multi-column';
  texts: string[];
};

type HomeScreenUiModel =
  | ListItemUiModel
  | LargeListItemUiModel
  | MultiColumnListItemUiModel;

HomeScreenUiModelListItemUiModel, LargeListItemUiModel, MultiColumnListItemUiModel からなるユニオン型です。
すなわち、HomeScreenUiModel はそれら3種類の型のうちどれかを表すこととなり、網羅的に条件分岐できます。
FlatListrenderItem に渡せるコンポーネントが1種類のため、その1種類のコンポーネントが、実際に表示する3種類のコンポーネントに分岐するよう実装してみます。
renderItem に渡すコンポーネント HomeScreenUiModelBinder の実装例は次のとおりです。

const HomeScreenUiModelBinder: React.FC<Props> = ({item}) => {
  switch (item.type) {
    case 'item':
      return <ListItem text={item.text} />;
    case 'large':
      return <LargeListItem text={item.text} />;
    case 'multi-column':
      return <MultiColumnListItem texts={item.texts} />;
  }
};

HomeScreenUiModel を構成する3種類の UiModel は共通して type プロパティを持っているので、type プロパティの型は item, large
, multi-column という3種類の文字列からなるユニオン型になります。
したがって、HomeScreenUiModel がもつ type プロパティの値によって網羅的に条件分岐できています。
HomeScreenUiModelBinderFlatList に渡し、data の内容を実際にリスト表示すると次のようになります。

const HomeScreen: React.FC<Props> = ({uiModels}) => (
  <FlatList<HomeScreenUiModel>
    data={uiModels}
    renderItem={item => <HomeScreenUiModelBinder item={item.item} />}
  />
);

以上で、要件を満たす UI を実装できました!

ここまで公称型における型定義と、構造的部分型における型定義を見てきました。
それぞれの雰囲気をなんとなく掴めたでしょうか。
どちらの例でも型という言葉を使用していましたが、その意味は異なっていました。
すなわち、

  • 公称型:型の名前そのものを定義する
  • 構造的部分型:型の構造を定義する

という違いがありました。
この違いが、Android エンジニアである私とフロントエンドエンジニアの友人との間で生まれた齟齬の原因となっていました。そして、私が TypeScript を学ぶ上での1つのハードルにもなっていたのです。

まとめ

最後に本章のまとめです。

  1. 同じ静的型付け言語でも、異なる型システムがある
    1. 公称型:構造が同じでも置換できない
    2. 構造的部分型:構造が同じ場合に置換できる
  2. 型定義
    1. フロントエンドエンジニアの友人:型の構造を定義する
    2. Android エンジニアの私:型の名前そのものを定義する

おわりに

最後まで読んでいただきありがとうございました!
技術書の執筆は今回が初めてで、とてもよい経験となりました。
ぜひ来年も挑戦させていただきたいです…!
各種 SNS のアカウントを載せておきますので、ご質問やアドバイスがあれば、ぜひぜひお気軽にご連絡ください。

サンプルコード

UI の実装例を次のリポジトリに置いています。
興味があればぜひご覧ください!

https://github.com/Kaito-Dogi/type-systems

参考文献

  1. 構造的部分型(structural subtyping)| TypeScript 入門『サバイバル
    TypeScript』
  2. Lists and grids | Compose | Android Developers
  3. Sealed classes and interfaces | Kotlin Documentation
  4. FlatList · React Native
  5. TypeScript: Handbook - Unions and Intersection
  6. ユニオン型(union type)| TypeScript入門『サバイバル TypeScript』
    Types
  7. Keishin Yokomaku,(2023), Kotlin で Either したい