diff --git a/articles/biome-vs-eslint-prettier-2025.md b/articles/biome-vs-eslint-prettier-2025.md new file mode 100644 index 0000000000..e41464feeb --- /dev/null +++ b/articles/biome-vs-eslint-prettier-2025.md @@ -0,0 +1,30 @@ +--- +title: 'ESLint, prettier の代替として見たときの Biome についての感想メモ' +emoji: '🐈' +type: 'tech' # tech: 技術記事 / idea: アイデア +topics: ['biome', 'eslint', 'prettier', 'typescript'] +published: false +--- + +## Biome のメリット(ユーザー目線) + +- 速度: + - BiomeはRustで書かれており、非常に高速に動作する。 +- 統合性: + - Biome は、リンター、フォーマッター、そして将来的にコンパイラなど、複数の機能を統合している。これにより、ツール間の互換性問題を減らし、開発体験を向上させることができる。 +- 設定の簡素化: + - Biome は、デフォルト設定が優れており、多くのプロジェクトで追加設定なしに利用できる。 + - biome.json でlint ルール等に補完が効くため記述体験が良い。 +- より厳密な構文解析: + - BiomeのパーサはPrettierのパーサよりも厳密に構文を解析する。そのため、より正確にエラーを検出し、コードを整形できる +- ESLint, prettier との比較 + - 後発であるため prettier や ESLint の色々な問題を解決しており将来性がある + +prettier plugin recommended config などを超えて ESLint の rule option をがっつり定義したものと比べると(開発思想上納得できるものの)カスタマイズできる余地が少なく機能が現状まだ劣るので使いづらい。 + +## 執筆時点(2025年2月24日)時点での筆者の結論 + +ESLint + prettier の方がコードの品質を保つ事に関しては現状 Biome より高機能であるため、当分は実行が遅くても ESLint + prettier を使うことになりそうです。 +以前 [ESLint を使い倒す(おすすめルール紹介)](https://zenn.dev/noshiro_piko/articles/take-full-advantage-of-typescript-eslint) という記事で様々な ESLint ルールを紹介しましたが、これらの中には Biome にはまだ実装されていないものや設定が現状多く存在します。既に主要な ESLint plugin から移植済みの lint ルールであっても option が削られているケースも確認しており(どれだったか思い出したら書き足します🙇)、もしかしたらこれが意図的な場合一部の option は今後も追加されないかもしれません。 + +Biome の基本設定で足りないところを、重複しないように特定のルール設定だけ ON にした ESLint と prettier を併用して補う、という使い方も一応可能ではありますが、ツールの統合・設定の簡素化というメリットはむしろ確実に悪化してしまう上に速度面で得をするのかすら怪しいため、あまり得策とは言えないと思われます。 diff --git a/articles/frontend-frameworks-survey.md b/articles/frontend-frameworks-survey.md new file mode 100644 index 0000000000..7f169ac030 --- /dev/null +++ b/articles/frontend-frameworks-survey.md @@ -0,0 +1,752 @@ +--- +title: '[WIP] ウェブフロントエンドフレームワーク Survey(加筆予定)' +emoji: '🐈' +type: 'tech' # tech: 技術記事 / idea: アイデア +topics: ['javascript', 'typescript', 'react', 'frontend', 'spa'] +published: false +--- + +:::message +なるべく気を付けますが、自分はいずれのフレームワークについてもその開発者レベルで実装の詳細を熟知しているわけではありませんので、不正確になりうる点はご了承ください。誤りがあればぜひコメント欄でご指摘ください。 +::: + +## 今回取り上げたフレームワーク・ライブラリとその理由(使用時期) + +- 業務でもプライベートでも使用経験があるもの + - [React](https://react.dev/) (2019年頃~現在) + - [Angular](https://angular.dev/) (v2) (2017年~2018年頃、2020~2022年頃) +- プライベートで使用経験があるもの +- Vanilla JS, jQuery(2015年?~2017年頃) + - [Preact](https://preactjs.com/) (2021年頃~現在) + - [Solid](https://www.solidjs.com/) (2021年頃) +- 名前は元から知っていたが少ししか書いたことがないもの + - [Svelte](https://svelte.jp/) + - [Vue](https://vuejs.org/) + - [Elm](https://elm-lang.org/) + - [PureScript](https://www.purescript.org/) + - [ReScript](https://rescript-lang.org/) +- Rust (WebAssembly) を活用するタイプの SPA フレームワークも比較したいため追加 + - [DIOXUS](https://dioxuslabs.com/) + - [Yew](https://yew.rs/) + - https://github.com/flosse/rust-web-framework-comparison により詳しい比較あり +- 今回 SPA フレームワーク調査中に存在を知ったもの + - [Inferno](https://www.infernojs.org/) + - [Ember](https://emberjs.com/) + - [Lit](https://lit.dev/) + - State of JS 2024 の top 10 から + - [Qwik](https://qwik.dev/) + - [Stencil](https://stenciljs.com/) + - [HTMX](https://htmx.org/) + +## State of JS + +https://2024.stateofjs.com/ja-JP/libraries/front-end-frameworks/ + +## 比較表 + +| | 使用する言語 | View の記述言語 | レンダリング方式 | 登場時期(年) | 特徴 | +| :--------- | :--------------------- | :-------------------------- | :--------------------------------------------------- | :------------- | :------------------------------------------------------------------------------------ | +| React | JavaScript, TypeScript | JSX | 仮想DOM | 2013 | 関数コンポーネント、hooks | +| Preact | JavaScript, TypeScript | JSX | 仮想DOM | 2015 | 関数コンポーネント、hooks、軽量 | +| Inferno | JavaScript, TypeScript | JSX | 仮想DOM | 2016 | 最適化された仮想DOM実装によりハイパフォーマンス | +| Solid | JavaScript, TypeScript | JSX | fine-grainedリアクティビティ | 2019 | 仮想DOMを使わず独自のリアクティヴシステムを提供、ハイパフォーマンス・省バンドルサイズ | +| Svelte | JavaScript, TypeScript | Svelte 構文 | | 2016 | コンパイル時にコードを最適化するフレームワーク | +| Vue | JavaScript, TypeScript | JSX or テンプレート構文 | 仮想DOM | 2014 | テンプレート用の記法でデータバインディングを記述する | +| Angular | JavaScript, TypeScript | テンプレート構文 | Incremental DOM | 2009 | テンプレート用の記法でデータバインディングを記述する | +| Ember | JavaScript, TypeScript | テンプレート構文 | Glimmer | 2011 | | +| Lit | JavaScript, TypeScript | TypeScript デコレーターなど | Web Componentsの更新APIを直接利用 | 2019年頃? | Web components を記述するための軽量ライブラリ | +| Stencil | JavaScript, TypeScript | JSX | Web Componentsの更新APIを直接利用 | 2017年 | Web Components をベースにしたコンパイラ | +| Qwik | JavaScript, TypeScript | JSX | Resumability & パーシャルハイドレーション | 2021年 | SSR との親和性を重視した設計により、高速なロードとインタラクティブ性を両立させる | +| Elm | Elm | Elm | 仮想DOM の最適化版 | 2012 | 静的型付けの純粋関数型プログラミング言語 | +| PureScript | PureScript | PureScript 独自構文 | 仮想DOM の最適化版 | 2014 | 静的型付けの純粋関数型プログラミング言語、Haskell の影響を強く受けた言語 | +| ReScript | ReScript | JSX or ReasonML | | 2020 | OCamlから派生した言語 | +| HTMX | HTMX, JavaScript | HTMX | サーバーサイドでHTMLを生成し、ブラウザで部分的な更新 | 2020年 | HTMLの拡張構文、JavaScript をほとんど書かずに動的な Web アプリケーションを開発できる | +| Yew | Rust, WebAssembly | | 仮想DOM に類似する最適化された差分検出システム | 2018年頃? | Rust で記述 | + +## 各フレームワークの特徴 + +### [React](https://react.dev/), [Preact](https://preactjs.com/) + +```tsx +import * as React from 'react'; + +export const Counter = () => { + const [count, setCount] = React.useState(0); + + const increment = () => { + setCount((c) => c + 1); + }; + + return ( +
+

{`カウント : ${count}`}

+ +
+ ); +}; +``` + +- 仮想DOM を使用 +- 関数コンポーネントと Hooks (useState など)で記述 + - class でも記述できるが現在使われることは稀 +- 😊 hooks によりコンポーネント内 state を1行で追加したり、ロジックを関数として共通化したりといった抽象化がシンプルに行える +- 😊 すべてをJavaScript の世界で完結させることができる(Angular などでは template 構文を扱う必要がある) +- 😊 `setState` などによりディスパッチされる"rendering" でコンポーネント関数が呼び出され、その内容がメモ化されたものを除き毎回すべて再評価され、あとは仮想DOMが勝手に差分を DOM に適用してくれる、というメンタルモデルさえ獲得すればほとんど問題無く実用できる。 +- [React のルール](https://ja.react.dev/reference/rules) を守る必要がある + - これは、 コンポーネントとフックを冪等にすることやprops と state はイミュータブルに扱うことなど、自然なコンポーネント記述をしていれば抵触しないものではあります。 + - Strict Mode で検出しやすくすることもできます。 +- 🙁 React hooks のルールを守る必要がある + - Rule of React hooks + 1. 最上位でのみ Hook を呼び出す + - Hook は、ループ、条件分岐、またはネストされた関数内で呼び出してはいけません。 + - Hook は、常に React 関数のトップレベルで呼び出す必要があります。 + 2. React 関数内でのみ Hook を呼び出す + - Hook は、通常の JavaScript 関数内で呼び出してはいけません。 + - Hook は、React 関数コンポーネント、またはカスタム Hook の中で呼び出す必要があります。 + - [`react-hooks/rules-of-hooks`](https://www.npmjs.com/package/eslint-plugin-react-hooks) でほぼ検出できます。 + - これは状態管理実装の仕方に React の実装上の都合での制約が生じるものであり、他フレームワークと比べるとデメリットと呼べる可能性があるものです。 + +### [Angular](https://angular.dev/) + +```ts +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-counter', + template: ` +
+

カウント: {{ count }}

+
{`${count % 2 === 0 ? '偶数' : '奇数'}`}
+ + +
+ `, + styles: [], +}) +export class CounterComponent { + count = 0; + + increment() { + this.count++; + } +} +``` + +- クラスベースコンポーネント +- style, template は別ファイルに分離することも可能 +- 🙁 覚えるべきルールが膨大 + - template 構文( `ngIf`, `ngFor`, `ngSwitch`, ... )を覚える必要ある。 + - コンポーネントを定義して NgModule に登録する、などの作業 +- 🙁 コード記述量が多い + - コンポーネントを一つ作成するのにも専用コマンドが用意されているほど +- 🙁 TypeScript 中のコードでありながら、 template 部分の Syntax Highlighting を行うには専用の拡張を入れる必要がある。 +- 🙁 コンポーネント作成の手間が大きい + + - template 内で参照するためのコンポーネント名 `app-counter` と JS 変数名 `CounterComponent` を別途定義する必要がある。 + - コンポーネントを定義した後 Module に別途追加しないと template 内で参照できない。 + + ```ts + import { NgModule } from '@angular/core'; + import { AppComponent } from './app.component'; + import { MyComponent } from './my-component/my-component.component'; // 追加 + + @NgModule({ + declarations: [ + AppComponent, + MyComponent, // 追加 + ], + imports: [BrowserModule], + providers: [], + bootstrap: [AppComponent], + }) + export class AppModule {} + ``` + +- レンダリングエンジン Ivy において、インクリメンタル DOM という技術を採用している。 + - 仮想DOM(Virtual DOM) との比較: + - 仮想DOM: JavaScript で DOM の構造を再現した仮想的な DOM ツリー(仮想DOM)を作成し、変更があった部分だけ実際の DOM に反映させる。 + - インクリメンタル DOM: 各コンポーネントを DOM 操作の命令列にコンパイルし、データの変更に応じて必要な部分だけを直接 DOM に変更を加える。 + - ツリーシェイカビリティとメモリ効率に優れる(らしい) + - フレームワーク間のパフォーマンス比較については後述しますが、Solid や Preact などのより優れた選択肢があるため採用の根拠にはならなさそうです。 + +### [Vue](https://vuejs.org/) + +- コンポーネント記述例([template 構文](https://vuejs.org/guide/essentials/template-syntax.html)) + + ```vue + + + + ``` + +- コンポーネント記述例([JSX](https://vuejs.org/guide/extras/render-function.html#jsx-tsx)) + + ```js + import { ref, reactive } from 'vue'; + + export const Counter = { + setup() { + // リアクティブなカウント変数を定義 + const count = ref(0); + + const increment = () => { + count.value++; + }; + + return () => ( +
+

カウント: {count.value}

+ +
+ ); + }, + }; + ``` + +- [Virtual DOM](https://vuejs.org/guide/extras/rendering-mechanism.html#virtual-dom) を使用 + - render function の実行結果である仮想DOMツリーの差分のみを Real DOM に適用するシステム。 + +![virtual-dom](https://github.com/noshiro-pf/mono/blob/develop/articles/frontend-frameworks/virtual-dom.png?raw=true) + +### [Ember](https://emberjs.com/) + +テンプレート + +```html +
+

Count: {{count}}

+ +
+``` + +コンポーネント + +```js +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; + +export default class CounterComponent extends Component { + @tracked count = 0; + + @action + increment() { + this.count++; + } +} +``` + +- Glimmer というレンダリングエンジンを採用している。 + - 各コンポーネントを DOM 操作の命令列にコンパイルし、データの変更に応じて必要な部分だけを直接 DOM に変更を加える +- Ruby on Rails の開発者でもある Yehuda Katz と Tom Dale によって2011年に開発された。 + +### [Inferno](https://www.infernojs.org/) + +加筆予定 + +### [Solid](https://www.solidjs.com/) + +- 特徴 + - React の影響を受けたフレームワーク + - 仮想 DOM を使わず独自のリアクティヴシステムを採用しておりパフォーマンスに優れる + ![benchmark1](https://github.com/noshiro-pf/mono/blob/develop/articles/frontend-frameworks/benchmark1.png?raw=true) + ![benchmark2](https://github.com/noshiro-pf/mono/blob/develop/articles/frontend-frameworks/benchmark2.png?raw=true) + +```tsx +import { onCleanup, createSignal } from 'solid-js'; +import { render } from 'solid-js/web'; + +const CountingComponent = () => { + const [count, setCount] = createSignal(0); + + const timer = setInterval(() => { + setCount((count) => count + 1); + }, 1000); + + onCleanup(() => { + clearInterval(timer); + }); + + return
{`Count value is ${count()}`}
; +}; + +render(() => , document.getElementById('app')); +``` + +- React Hook のようなルールを理解する必要が無い + - Hook は、ループ、条件分岐、またはネストされた関数内で呼び出してはいけません。 + Hook は、常に React 関数のトップレベルで呼び出す必要があります。 +- すべてのコンポーネントは一度だけ評価(実行)され、依存関係が更新されるたびに実行されるのは hook と binding だけ + +見た目同じ動作をする React コンポーネント実装: + +```tsx +import * as React from 'react'; + +export const CountingComponent = () => { + const [count, setCount] = React.useState(0); + + useEffect(() => { + const timer = setInterval(() => { + setCount((c) => c + 1); + }, 1000); + + return () => { + clearInterval(timer); + }; + }, []); + + return
{`Count value is ${count}`}
; +}; +``` + +#### 使用時の注意点 + +リアクティヴシステムの仕組みを意識せねばならないシーンにちょくちょく遭遇する + +1. ダメな例 その1: props を destruct するとリアクティヴな値の伝播が途切れて正しく動かない + + ```tsx + const ViewComponent = ({ count }) => ( + // ~~~~~~~~~ + // Destructuring component props breaks Solid's reactivity; + // use property access instead. eslint(solid/no-destructure) +
{`Count value is ${count()}`}
+ // ↑ この値が 0 のまま動かなくなる + ); + + const CountingComponent = () => { + const [count, setCount] = createSignal(0); + // 中略 + return ; + }; + ``` + + 正しい実装([`solid/no-destructure`](https://github.com/solidjs-community/eslint-plugin-solid/blob/main/packages/eslint-plugin-solid/docs/no-destructure.md) という eslint ルールで自動修正可能) + + ```tsx + const ViewComponent = (props) => ( +
{`Count value is ${props.count()}`}
+ ); + ``` + +2. ダメな例 その2:(配列を JSX 内で `Array.prototype.map` などで展開してはダメ) + + ```tsx + const Component = (props) => ( +
    + {props.data.map((d) => ( +
  1. {d.text}
  2. + ))} +
+ ); + ``` + + 正しい実装([`prefer-for`](https://github.com/solidjs-community/eslint-plugin-solid/blob/main/packages/eslint-plugin-solid/docs/prefer-for.md)という eslint ルールで自動修正可能) + + ```tsx + const Component = (props) => ( +
    + {(d) =>
  1. {d.text}
  2. }
    +
+ ); + ``` + +3. ダメな例 その3:(JSX 内で三項演算子などを用いてはダメ) + + ```tsx + const Component = (props) => ( +
{props.cond ? Content : Fallback}
+ ); + ``` + + 正しい実装([`prefer-show`](https://github.com/solidjs-community/eslint-plugin-solid/blob/main/packages/eslint-plugin-solid/docs/prefer-show.md)という eslint ルールで自動修正可能) + + ```tsx + const Component = (props) => ( +
+ Fallback}> + Content + +
+ ); + ``` + +これらの例が動かないのは、 Solid の状態管理で使われる `Signal` は純粋な Object ではなく、 [`Proxy`](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Proxy) から作られた特殊なObjectであることが原因です。 Proxy は Object の Getter と Setter の動作を乗っ取り、それらのプロパティの値が読まれたり、変えられたときに行う処理を改変する仕組みです。 + +例えば [Immer](https://immerjs.github.io/immer/#how-immer-works) は Proxy を使った JS の有名ライブラリの一つです。 immer の `produce` 関数が提供する `draft` オブジェクトは Proxy でできており、この `draft` に対する破壊的操作は `currentState` オブジェクトを改変することも丸ごと deep copy することもなく `nextState` を作ってくれます。 + +Solid ではこの Proxy をリアクティヴな変数を実現するために使用しており、そのためプログラマーは前述のような良くない例を踏まないようにリアクティヴィティを保つために慎重にコードを書く必要があります。配列の展開には ``、条件分岐には `` を使う必要があります。 + +ただし、数年前 Solid を試したときには eslint plugin が整備されていなかったか私が存在を認知していなかったので、リアクティヴィティを保つ実装に注意力が結構必要だなという印象を受けたのですが、いつの間にか自動修正含め色々整備されていたのでほとんど困ることはないかもしれません。 + +### [Svelte](https://svelte.jp/) + +- コンパイル時にコードを最適化する(仮想DOMを使用しない) + - 実行時のパフォーマンスが良く、バンドルサイズが小さい + +```html + + +
+

カウント: {count}

+

カウント x 2: {doubled}

+
{count % 2 === 0 ? "偶数" : "奇数"}
+
{#if count % 2 === 0}{:else}{/if}
+ +
+``` + +even.svelte + +```svelte +
+ Even +
+``` + +odd.svelte + +```svelte +
+ Odd +
+``` + +- 1ファイル1コンポーネントが強制される + - コードの治安が保たれるという見方もできる半面、わざわざファイルに切り出すまでもない小さな共通コンポーネントをローカルに繰り返し使いたいユースケースでちょっと不便になりそう。 + +### [Elm](https://elm-lang.org/) + +```elm +module Counter exposing (main) + +import Browser +import Html exposing (..) +import Html.Events exposing (onClick) + + +type Model = + { count : Int } + + +init : Model +init = + { count = 0 } + + +type Msg + = Increment + + +update : Msg -> Model -> Model +update msg model = + case msg of + Increment -> + { model | count = model.count + 1 } + + +view : Model -> Html Msg +view model = + div [] + [ p [] [ text ("カウント : " ++ String.fromInt model.count) ] + , button [ onClick Increment ] [ text "カウントアップ" ] + ] + + +main : Program () Model Msg +main = + Browser.sandbox + { init = init + , view = view + , update = update + } +``` + +加筆予定 + +### [PureScript](https://www.purescript.org/) + +```purescript +module Counter where + +import Prelude +import Effect (Effect) +import React.Basic +import React.Basic.DOM (div, p, button, text) +import React.Basic.Events (onClick) +import React.Hooks (useState) + +component = React.Basic.component "Counter" \ctx -> do + (count, setCount) <- useState 0 + + pure do + div [] do + p [] [ text $ "カウント : " <> show count ] + button [ onClick $ \_ -> setCount (add 1 count) ] [ text "カウントアップ" ] +``` + +加筆予定 + +### [ReScript](https://rescript-lang.org/) + +```rescript +import React from 'react'; +import * as ReactHooks from 'react'; + +@react.component +let make = () => { + let (count, setCount) = ReactHooks.useState(() => 0); + + let increment = () => setCount(prev => prev + 1); + +
+

{React.string( "カウント : " ++ string_of_int(count)) }

+ +
+}; +``` + +加筆予定 + +### [DIOXUS](https://dioxuslabs.com/) + +```rust +use dioxus::prelude::*; + +fn main() { + dioxus::desktop::launch(App); +} + +fn App(cx: Scope) -> Element { + let (count, set_count) = use_state(&cx, || 0); + + cx.render(rsx! { + div { + p { "カウント : {count}" } + button { onclick: move |_| set_count(count + 1), "カウントアップ" } + } + }) +} +``` + +加筆予定 + +### [Yew](https://yew.rs/) + +```rust +use yew::prelude::*; + +enum Msg { + AddOne, +} + +struct Counter { + count: i64, +} + +impl Component for Counter { + type Message = Msg; + type Properties = (); + + fn create(_ctx: &Context) -> Self { + Self { count: 0 } + } + + fn update(&mut self, _ctx: &Context, msg: Self::Message) -> bool { + match msg { + Msg::AddOne => { + self.count += 1; + true + } + } + } + + fn view(&self, ctx: &Context) -> Html { + html! { +
+

{ format!("カウント : {}", self.count) }

+ +
+ } + } +} + +fn main() { + yew::start_app::(); +} +``` + +加筆予定 + +### [Lit](https://lit.dev/) + +```tsx +import { LitElement, html, css } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +@customElement('my-counter') +export class MyCounter extends LitElement { + static styles = css` + p { + color: blue; + } + `; + + @property({ type: Number }) count = 0; + + render() { + return html` +
+

カウント : ${this.count}

+ +
+ `; + } + + private _onClick() { + this.count++; + } +} +``` + +加筆予定 + +### [Qwik](https://qwik.dev/) + +```tsx +import { component$, useSignal } from '@builder.io/qwik'; + +export const Counter = component$(() => { + const count = useSignal(0); + + const increment = () => { + count.value++; + }; + + return ( +
+

カウント : {count.value}

+ +
+ ); +}); +``` + +加筆予定 + +### [Stencil](https://stenciljs.com/) + +```tsx +import { Component, h, State } from '@stencil/core'; + +@Component({ + tag: 'my-counter', + shadow: true, +}) +export class MyCounter { + @State() count = 0; + + render() { + return ( +
+

カウント : {this.count}

+ +
+ ); + } +} +``` + +加筆予定 + +### [HTMX](https://htmx.org/) + +加筆予定 + +--- + +## 筆者がフロントエンドフレームワークを選ぶなら + +以上、代表的なフロントエンドフレームワークについてまとめました。 + +筆者は、現在はメンテナンスしているすべてのフロントエンド実装に React か Preact を使用しています。使用時期や期間に差はありますが、過去には VanillaJS/jQuery や Angular なども実用アプリケーション開発に使用した経験(数千行以上)がありますが、今後当分は React/Preact を使うのが結論かなと考えています。 +そこで React をベースとして他フレームワークのどういうところに利点を感じるか、どういうところに不満を感じる(あるいは感じそう)かを考えてみたいと思います。 + +### パフォーマンス観点 + +パフォーマンス面を重視する場合、Solid, Svelte, Inferno, などが React と比べて特にパフォーマンスに優れているようですが、この中で選ぶなら私は **Solid** を選びたいかなと思います(今更宣言的 UI を捨てて Vanilla JS で書くという選択肢はほぼ無いでしょう)。 +**Inferno** は syntax が class component で記述する旧 React に近く、 hooks と関数コンポーネントで記述できる React 等と比べてあまり書きやすくはなさそうです。仮想DOMの実装が bit 演算を使用することなどにより React より高速化されているらしく、十分高いパフォーマンスを示していますが、仮想DOMを使っていることには変わりが無いので、リアクティヴィティを直接管理しようとする Svelte や Solid と比べると、ランタイムパフォーマンス・バンドルサイズで僅かに劣るようです。 +**Svelte** はコンパイル時にコードを最適化しますが、結局 Solid と同じように仮想DOMを使わずにリアクティヴな値の伝播を管理する点では本質的な動作は同じものになると考えられ、パフォーマンス面で大きな優劣は付かないのではないかと思います。benchmark では僅かに Solid が勝るようです。また、小さなローカルコンポーネントを記述することがおそらくできないため柔軟性に若干欠けるような気もします。 +**Solid** は React hooks に慣れた開発者にとってこの3つのうち最も親和性の高い選択肢だと思います。先述の通り JSX 記述時にいくつかの注意点がありますが、代わりに `useMemo`, `useCallback` hooks などや `React.memo` などによりメモ化のためのコードを記述する手間を大幅に削減できるため開発体験が非常に良さそうです。 Svelte と異なり、 Solid は(Svelteと同じくコンパイラでもありますが) TypeScript 上で使用するライブラリであるため、 Syntax Highlighting がしやすいという利点もあります。 + +ただ、これまで経験上 React のレンダリングエンジンだと遅くてどうしようもないから他を使いたい、という状況に遭遇したことがほとんど無いため、パフォーマンスを改善するためにReact 以外のフレームワークを使う強い動機は持ち合わせていないというのが正直なところです。 +結局、宣言的UIとコンポーネントを実現する上での選択肢は + +- rendering のたびにコンポーネントツリーを全部再計算してしまう(適宜メモ化する)が、仮想DOMを挟むことで差分を取って DOM に反映するところだけ最適化する(React, Preact などのやり方) +- 不要な再計算を省くために、自動的にリアクティヴに再計算する対象をコードとして記述し管理する(Solid や Svelte のやり方) + +の二択になりそうで、後者はリアクティヴィティのための記述がどうしても発生することになるため、パフォーマンスとコード記述量のトレードオフが存在するのが現状なのかなという気がするのですが、それならばパフォーマンス面の要求が相当強くならない限りは syntax の良し悪し(場合によっては慣れ)を優先して良いのかな、という気持ちです。 + +その点、 React との親和性の高さとパフォーマンス・省バンドルサイズを両立できる選択肢として **Preact** も有力です。独自リアクティヴシステムを持つ Solid, Svelte ほどの理論値は追求できませんが、 多くの場合実用上十分なのかなと思っています。react との親和性を重視して作られており、適切に import 先の alias を設定することで React 製のライブラリを流用できる点が魅力です。実際私はいくつかのアプリケーションでは現在も採用しています。自分が Solid を採用する場合は実用上の要求というよりは技術的興味が大きな理由となりそうです。 +**Vue**, **Angular** はいくつかの benchmark 上 React より優れていますが Preact や Solid ほどではなく、syntax の観点でデメリットが大きいと感じるため自分が採用する可能性はほぼ無さそうです。 + +### Syntax 観点 + +component や hooks をメモ化する手間が無いこと、 hook の記述ルールの少なさで Solid は優れていますが、代わりに React や Preact には無い Proxy オブジェクトの維持にまつわる JSX 内の記法の制約が生じるのが若干煩わしいような気もします。制御構文のための `For` や `Show` などの import の手間も気になるのかなと思いましたが、これは `useMemo` や `useCallback` などの import の手間との取引なので優劣はあまり付かないかなと思います。 + +メモ化のためのボイラープレートコードのうちフックを使うものは、グローバルステートに状態管理を寄せることで、コンポーネント内にコールバック関数やリアクティヴな変数を持たずに済むようにすることで大部分を回避することができます。そうなると、`React.memo` だけがボイラープレートとして残ることになりそうです。 + +加えて、React の場合は現在 [React Compiler](https://ja.react.dev/learn/react-compiler) というものがベータ版で提供されており、 `useMemo` や `React.memo` などによるメモ化のためのコードを自動で挿入することができるようです。まだ試せていないのですが、これを最大限活用できれば、メモ化のための多くの記述を完全に省いた React 関数コンポーネントは(`` や `` などの JSX 内制御構文を必要とする)Solid に比べても真に少ないコンポーネント記述・import を実現できるので、 TypeScript の範疇でのフロントエンド実装の syntax の最適解の一つとなりそうな予感がします。 + +### 言語 + +TypeScript には JavaScript のスーパーセットであるという明確な利点がありますが、を使う以上どうしても JavaScript の負債も引き継ぐことになってしまうため、より洗練された堅牢性・保守性に優れた言語を使いたい場合は他の言語を検討する手もありそうです。 + +TypeScript よりも安全な言語でフロントエンドを構築したい場合に、 Elm, PureScript, ReScript などの純粋関数型言語を採用するのは有力です。 ReScript は比較的 JavaScript エンジニアが親しみやすい syntax をしているのと、比較的可読性の高い JS/TS コードを出力することが一応できるため移行のハードルが他二つよりは低いです。ただ、試した感じだと自分の手で書いたコードを置き換えられるほどのクオリティのコードは吐いてくれないため、 React&TypeScript プロジェクトの一部コードから置き換えていくような使い方はあまりする気にはなりませんでした。 + +Rust を使うタイプのフロントエンドフレームワークは、バックエンドにも Rust を使う場合などに言語を統一する選択肢として面白そうです。 使用できるかどうかは [WASM Browser Compatibility](https://developer.mozilla.org/en-US/docs/WebAssembly#browser_compatibility) を確認する必要があるかもしれません。 WASM のバンドルサイズが大きいというデメリットがあるらしいのと、マクロによる記述がエディタで補完が効きづらく書きづらいという噂を聞いたことだけあるので、このあたり詳しく調べてみようと思います。 + +結局抜き差しならない問題として、コンポーネントライブラリ([Material UI](https://mui.com/material-ui/?srsltid=AfmBOormP6d7WQO8yFX4g1sLIYwFSP5IkFbXkelCkVuTNcN99900hDvz) や [Blueprint](https://blueprintjs.com/docs/) など)などで React 向けのものを使いたいとなるとReact を選んでおくのが無難になりやすいと思います。デザインは自作で済むなど外部依存無く実装できることが分かっている状況では、それ以外のフレームワークを採用する可能性がありそうです。 + +## TODO + +- benchmark のコードを詳しく読んでみる + - 公平な比較になっているのか自分の目で確認したい +- Vanilla JS/jQuery による命令的 UI は何がつらかったのかを言語化してみる + - 本記事では宣言的UIを実現するもののみを比較していたため +- Rust を使うタイプのフロントエンドフレームワークについて詳しく調べる diff --git a/articles/frontend-frameworks/benchmark1.png b/articles/frontend-frameworks/benchmark1.png new file mode 100644 index 0000000000..784022be1d Binary files /dev/null and b/articles/frontend-frameworks/benchmark1.png differ diff --git a/articles/frontend-frameworks/benchmark2.png b/articles/frontend-frameworks/benchmark2.png new file mode 100644 index 0000000000..eb5284d215 Binary files /dev/null and b/articles/frontend-frameworks/benchmark2.png differ diff --git a/articles/frontend-frameworks/virtual-dom.png b/articles/frontend-frameworks/virtual-dom.png new file mode 100644 index 0000000000..e89cbf958f Binary files /dev/null and b/articles/frontend-frameworks/virtual-dom.png differ diff --git a/articles/take-full-advantage-of-typescript-eslint.md b/articles/take-full-advantage-of-typescript-eslint.md index 84fabb1986..9f67c600ee 100644 --- a/articles/take-full-advantage-of-typescript-eslint.md +++ b/articles/take-full-advantage-of-typescript-eslint.md @@ -727,7 +727,7 @@ JavaScript の `Array.prototype.sort` はデフォルトで文字列比較によ ``` この ESLint ルールを有効にすると `.sort()` の引数を省略できないようになります。 -文字列の配列に関しては比較関数省略時のデフォルト動作が自然であり採用したい場合が多いので、 `ignoreStringArrays` option も有効にしておくのが有効です。 +文字列の配列に関しては比較関数省略時のデフォルト動作が自然であり採用したい場合が多いので、 `ignoreStringArrays` option も有効にしておくと便利です。 ```json { diff --git a/articles/typescript-branded-type-int.md b/articles/typescript-branded-type-int.md index d058cc6c56..cb8ab3c82e 100644 --- a/articles/typescript-branded-type-int.md +++ b/articles/typescript-branded-type-int.md @@ -9,29 +9,31 @@ published: true ## 更新履歴 - (2025/01/11) 「Branded Type を使用したときの弊害」を追記 -- (2025/01/20)コード例の一部で tag 部分に `never` 型を用いていたのを `unknown` 型に修正 +- (2025/01/20)コード例の一部でBrand 型のオブジェクト部分に `never` 型を用いていたのを `unknown` 型に修正 - 参考: [誤解されがちなnever型の危険性: 「存在しない」について](https://qiita.com/uhyo/items/97941f855b2df0a99c60?utm_campaign=post_article&utm_medium=twitter&utm_source=twitter_share) ## 概要 -TypeScript で用いられることのある Type branding というハックと既存のいくつかのライブラリでのその実装例を説明し、次に、より安全かつ便利に Type branding を使うためのユーティリティの実装や ESLint 設定も紹介します。 +TypeScript で用いられることのある Type branding というハックと、既存のいくつかのライブラリでのその実装例を説明し、次に、より安全かつ便利に Type branding を使うためのユーティリティの実装や ESLint 設定も紹介します。 最後に Type branding の活用例として、数値型を `number` より細かく使い分けられるように Branded Type を定義する実装例を載せています。 Branded Type のよくある実装に、筆者が最近思いついたちょっとした工夫を入れることで数値型の Branded Type を上手く実装できたので紹介してみました。 + + ## Type branding とは -Structural Typing を採用している TypeScript では、例えば複数の異なる ID 文字列の型 (`UserId`, `ProjectId`, ... )を区別したいというような状況で、以下のように type alias を作っても、単なる string と同等に扱われてしまい区別できません。 +Structural Typing を採用している TypeScript では、例えば複数の異なる ID 文字列の型 (`UserId`, `PostId`, ... )を区別したいというような状況で、以下のように type alias を作っても、単なる string と同等に扱われてしまい区別できません。 ```ts type UserId = string; -type ProjectId = string; -type Project = { id: ProjectId; name: string }; +type PostId = string; +type Post = { id: PostId; name: string }; -declare function findProject(id: ProjectId): Promise; +declare function findPost(id: PostId): Promise; const userId: UserId = 'user-1'; -// userId を使って findProject を呼んでいるがエラーにならない -findProject(userId); +// userId を使って findPost を呼んでいるが型エラーにならない +findPost(userId); ``` しかしこのような問題を解決するための手段として **Type branding** というハックが知られています。 @@ -40,20 +42,21 @@ Type branding を用いると、以下のようにしてそれぞれの id 型 ```ts type UserId = string & { UserId: unknown }; -type ProjectId = string & { ProjectId: unknown }; -type Project = { id: ProjectId; name: string }; +type PostId = string & { PostId: unknown }; +type Post = { id: PostId; name: string }; -declare function findProject(id: ProjectId): Promise; +declare function findPost(id: PostId): Promise; const userId: UserId = 'user-1' as UserId; -// Argument of type 'UserId' is not assignable to parameter of type 'ProjectId'. -findProject(userId); +findPost(userId); +// ~~~~~~ +// Argument of type 'UserId' is not assignable to parameter of type 'PostId'. userId.slice(); // userId は通常の string でもある ``` -Type branding とは、対象となる型(この例では `string`)に `{ tagName: unknown }` という実際の値とは無関係のダミーのオブジェクト型を交差型として付け加えることで、構造上の互換性を破るテクニックです。こうすることで作られた型(Branded Type) `userId` はただの string 型とは異なる型になり、この例における `findProject` を呼び出してしまうようなミスを防ぐことができるようになります。 +Type branding とは、このように対象となる型(この例では `string`)に `{ brandTag: unknown }` という実際の値とは無関係のダミーのオブジェクト型を交差型として付け加えることで、構造上の互換性を破るテクニックです。こうすることで作られた型(Branded Type) `userId` はただの string 型とは異なる型になり、この例における `findPost` を呼び出してしまうようなミスを型チェックで防ぐことができるようになります。 ## Branded Type の様々な実装 @@ -61,7 +64,7 @@ Type branding とは、対象となる型(この例では `string`)に `{ ta [io-ts](https://github.com/gcanti/io-ts/blob/master/index.md#branded-types--refinements)[^io-ts] -[^io-ts]: io-tsでは、`Brand` という型により unique symbol をキーに持つオブジェクト型にネストさせることで、カスタム実装した型定義や他のライブラリ製の branded type との衝突を防ぐ仕組みになっています。本記事の Branded Type 定義では分かりやすさのためこのような実装はせずに説明しました。 +[^io-ts]: io-tsでは、`Brand` という型により `unique symbol` をキーに持つオブジェクト型にネストさせることで、カスタム実装した型定義や他のライブラリ製の branded type との衝突を防ぐ仕組みになっています。本記事の Branded Type 定義では分かりやすさのためこのような実装はせずに説明しました。 ```ts declare const _brand: unique symbol; @@ -79,21 +82,23 @@ type Int = number & Brand<{ readonly Int: unique symbol }>; type Int = number & { __type__: 'Int' } & { __witness__: number }; ``` -どちらの方法も型を区別するという要件は満たせますが、 tag object の key 部に型 ID を置く前者のやり方の方が、以下のように `&` で意味のある交差型を作ることができる点で便利そうです(後者の方法では `never` に潰れてしまいます)。 +どちらの方法も型を区別するという要件は満たせますが、オブジェクト型の value 部に型 ID を書く ts-brand の方法は、 `__type__` などのタグ名を予約するというルールが増えてしまうのも少し気になりますし、key 部に型 ID を置く io-ts のやり方の方が、以下のように `&` で意味のある交差型を作ることができる点で便利そうです(ts-brandの方法では `never` に潰れてしまいます)。 ```ts -type Int = number & { Int: unique symbol }; -type Positive = number & { Positive: unique symbol }; -type PositiveInt = Positive & Int; // number & { Positive: unique symbol } & { Int: unique symbol } +type Int = number & { Int: unknown }; +type Positive = number & { Positive: unknown }; +type PositiveInt = Positive & Int; // number & { Positive: unknown } & { Int: unknown } ``` -また、後者の tag の value 部に型 ID を書く方法は、 `__type__` などのタグ名を予約するというルールが増えてしまうのも少し気になります。 +また、その場合の value 部の型は `any` や `never` ではなく `unknown` や `unique symbol` にしておくのが良さそうです。こうしておけば、上の `Int` 型で宣言した変数 `a` に対して `a.Int` のようにプロパティアクセスしてしまっても、その結果が `unknown` 型にしかならないため間違ってどこかで使ってしまうリスクが他の型よりは低くなります。 -今回は前者の key 部に型 ID を書く方法が都合が良いので以降はこちらを採用します。 +また、これをより安全にするために key 部を `unique symbol` で実装することでそもそものプロパティアクセスもできなくしてしまうという方法があります([Branded Type ベストプラクティス 検索](https://qiita.com/uhyo/items/de4cb2085fdbdf484b83) でベストプラクティスとされているやり方です)。 + +なお、本記事の `## [応用] Branded Type で数値型を細分化` の実装では `unique symbol` を key にすることも可能ですが、コードが長くなり分かりづらくなってしまうことを避けるため、単に文字列リテラル型を用いたコード例で説明しています。また、 value 部で述語を表現できるようにするため、意図的に `unknown` や `unique symbol` ではなく `boolean` 型を使用しています。 ## より安全に Branded Type を使う方法(型ガード関数の定義、ESLint 設定) -Branded Type で変数に型注釈を付けるときには一つ不安要素があります。それは `as` によるキャストが嘘である可能性があることです。 +Branded Type で変数に型注釈を付けるときには一つ不安要素があります。それは `as` によるキャストが嘘になる可能性があることです。 ```ts type Int = number & { Int: unknown }; @@ -104,7 +109,7 @@ function numberToString(n: number, radix?: Int): string { return n.toString(radix); } -numberToString(12345, r); +numberToString(12345, r); // Uncaught RangeError: toString() radix argument must be between 2 and 36 ``` これを多少改善するために、 Branded Type 定義と共にガード関数と生成関数をセットで用意する方法が考えられます。 `as` を使ったキャストは unsafe ですが、以下のようにすると型名に合う値であることを保証しやすくなります。 @@ -118,16 +123,16 @@ function isInt(a: number): a is Int { return Number.isInteger(a); } -function toInt(a: number): Int { +function castToInt(a: number): Int { if (!isInt(a)) { - throw new Error(`a non-integer number "${a}" was passed to "toInt"`); + throw new Error(`a non-integer number "${a}" was passed to "castToInt"`); } return a as Int; } // main.ts -const r: Int = toInt(0.1); // ここで早期にエラーで気づける +const r: Int = castToInt(0.1); // ここで早期にエラーで気づける function numberToString(n: number, radix?: Int): string { return n.toString(); @@ -155,7 +160,7 @@ export type Int = z.infer; export const isInt = (a: number): a is Int => Int.safeParse(a).success; -export const toInt = (a: number): Int => Int.parse(a); +export const castToInt = (a: number): Int => Int.parse(a); ``` [io-ts](https://github.com/gcanti/io-ts/blob/master/index.md#branded-types--refinements) の使用例:[^io-ts-int] @@ -178,7 +183,7 @@ export type Int = t.TypeOf; export const isInt = (a: number): a is Int => E.isRight(Int.decode(a)); -export const toInt = (a: number): Int => { +export const castToInt = (a: number): Int => { const ret = Int.decode(a); if (E.isLeft(ret)) { throw new Error(ret.left.toString()); @@ -187,7 +192,7 @@ export const toInt = (a: number): Int => { }; ``` -もちろん、これだけでは `toInt` や `isInt` などのユーティリティを使わず `as Int` と書いてしまうことを禁止できているわけではないので、さらに ESLint によりチェックすると良さそうです。 +もちろん、これだけでは `castToInt` や `isInt` などのユーティリティを使わず `as Int` と書いてしまうことを禁止できているわけではないので、さらに ESLint によりチェックすると良さそうです。 以下のように設定することでこれをチェックできます。 @@ -200,14 +205,14 @@ export const toInt = (a: number): Int => { "error", { "selector": "TSAsExpression[typeAnnotation.typeName.name='Int']", - "message": "use toInt or isInt instead" + "message": "use castToInt or isInt instead" } ] } } ``` -## Branded Type を使用したときの弊害 +## Branded Type を使用したコードの弊害 Branding は TypeScript でのコーディングにおいて便利な道具ですが、あくまでユーザー側で慣例的に行われている「ハック」であり[^type-branding-is-hack]、TypeScriptに公式にサポートされている実装パターンというわけではないことに注意が必要です。 @@ -216,7 +221,8 @@ Branding は TypeScript でのコーディングにおいて便利な道具で 具体的には、例えば以下のようなコードで好ましくない挙動に遭遇します。 ```ts -type SafeUint = number & { readonly SafeUint: unknown }; +// 非負整数型 +type Uint = number & { readonly Uint: unknown }; const findIndex = (xs: readonly number[], x: number): number => xs.indexOf(x); @@ -229,26 +235,26 @@ const fn1 = (): 0 | 1 | undefined => { return undefined; }; -const findIndexBranded = (xs: readonly number[], x: number): SafeUint | -1 => - xs.indexOf(x) as SafeUint | -1; +const findIndexBranded = (xs: readonly number[], x: number): Uint | -1 => + xs.indexOf(x) as Uint | -1; const fn2 = (): 0 | 1 | undefined => { - const i: SafeUint | -1 = findIndexBranded([], 1); + const i: Uint | -1 = findIndexBranded([], 1); if (i === 0 || i === 1) { - // `i` は `0 | 1` 型に絞られず SafeUint のまま!(`-1` だけは除去される) - return i satisfies SafeUint; - // ~~~~~ + // `i` は `0 | 1` 型に絞られず Uint のまま!(`-1` だけは除去される) + return i satisfies Uint; + // ~~~~~~~~~~~~~~~~~~~~ // Type Error } return undefined; }; ``` -このコードにおいて、普通の `number` 型を使っている一つ目の例 `fn1` ではうまく型の絞り込みができますが、二つ目の例の `fn2` のようにbrand 化した `number` 型である `SafeUint` 型は `number` のサブタイプであるため、即値 `0`, `1` との比較による絞り込みができないという悩みが生じます。 +このコードにおいて、普通の `number` 型を使っている一つ目の例 `fn1` ではうまく型の絞り込みができますが、二つ目の例の `fn2` のようにbrand 化した `number` 型である `Uint` 型は `number` のサブタイプであるため、即値 `0`, `1` との比較による絞り込みができないという悩みが生じます。 -この絞り込みに失敗するのは、 `number` 型は型 `0` や型 `1` の上位型であるのに対し、 brand 型 `SafeUint` は型 `0` や型 `1` の上位型ではないことが理由です。条件部で `SafeUint & 0` 型の即値との比較などができれば `SafeUint` の部分型であるため絞り込みができそうですが、行いたい処理の割にコードが複雑化・コード量が増えるのがネックです。 +この絞り込みに失敗するのは、 `number` 型は型 `0` や型 `1` の上位型であるのに対し、 brand 型 `Uint` は型 `0` や型 `1` の上位型ではないことが理由です。条件部で `Uint & 0` 型の即値との比較などができれば `Uint` の部分型であるため絞り込みができそうですが、行いたい処理の割にコードが複雑化・コード量が増えるのがネックです。 -ちなみにこの例のようなケースは、部分的に `i` を `SafeUint` から `number` 型に広げてから即値との比較を行うという手はあります。関数内のローカル変数 `i` の型から `SafeUint` 型という情報が落ちるくらいは許容できそうです。 +ちなみにこの例のようなケースは、部分的に `i` を `Uint` から `number` 型に広げてから即値との比較を行うという手はあります。関数内のローカル変数 `i` の型から `Uint` 型という情報が落ちるくらいは許容できそうです。 ```ts const fn2_2 = (): 0 | 1 | undefined => { @@ -297,7 +303,7 @@ interface Array { こういった事情もあり、整数を使うべき箇所でもほぼ浮動小数点数 `number` を(諦めて)使っていることがほとんどです。 また、整数しか入力として想定していないような関数を定義するときも仮引数に `number` 型を使わざるを得ない以上、ちゃんとやるなら `Number.isInteger` などでチェックするバリデーションコードを関数の初めに書くべきではあるのですが、いちいちバリデーションを書くのは手間ですし、どうせすべての箇所で厳密にやるのが現実的にほぼ無理ということで、特に重要な場合以外は省いてしまうことも多そうです。 そもそも、型を信じることで(TypeScript の型による保証は絶対ではないので、信じられるように注意してコードを書くことで)そういったバリデーションコードをすべての関数に都度書く手間とランタイムコストを省けるのが JavaScript と比較したときの TypeScript の大きなメリットの一つであるはずなので、数値型の制約も型でなるべく保証できている状態が自然に思えます。 -特定の条件を満たす数値型(整数など)だけを受け取る関数では、それを**型で明示してある方が、関数にバリデーションコードを書く手間とランタイムチェックコストを省ける上に関数のインターフェースも分かりやすくなる**メリットがありそうです。 +特定の条件を満たす数値型(整数など)を受け取る関数は、それを型で明示してある方が関数にバリデーションコードを書く手間とランタイムチェックコストを省ける上に関数のインターフェースも分かりやすくなるメリットがありそうです。 そこで、今回は組み込みの `Number` オブジェクトに生えている各種バリデーション関数に対応する Branded number type を実装することにします。 @@ -325,7 +331,7 @@ type PositiveNegativeNumber = PositiveNumber & NegativeNumber; // できれば never になってほしい。 ``` -そこで、以下のように **Branded Type の tag 部の value に `unknown` ではなく `true/false` を持たせる**という工夫を考えてみました。 +そこで、以下のように **Branded Type のオブジェクト型の value に `unknown` ではなく `true/false` を持たせる**という工夫を考えてみました。 ```ts type PositiveNumber = number & { Positive: true }; @@ -334,7 +340,7 @@ type NegativeNumber = number & { Positive: false }; type PositiveNegativeNumber = PositiveNumber & NegativeNumber; // never ``` -こうすると、 `true & false` が `never` なので全体も `never` になってくれます。意味的には、 tag が表す述語が真になる場合は `true` 、偽になる場合は `false` を割り当てる、という風にして型を分類しています。 +こうすると、 `true & false` が `never` なので全体も `never` になってくれます。意味的には、オブジェクト型の key が表す述語が真になる場合は `true` 、偽になる場合は `false` を割り当てる、という風にして型を分類しています。 正確には `0` や `NaN` が含まれないようにもしたいため、もう少し意味的に正確な型になるよう修正してみます。 @@ -357,11 +363,7 @@ type NegativeNumber = number & { 直接書いても良いのですが、 Branded Type を作る型ユーティリティも用意してみます。 ```ts -export type Brand< - T, - TrueKeys extends string, - FalseKeys extends string = never, -> = T & { +type Brand = T & { readonly [key in FalseKeys | TrueKeys]: key extends TrueKeys ? true : false; }; ``` @@ -376,7 +378,7 @@ type NegativeNumber = Brand; 第 3 引数のデフォルト値を `never` にしているので、冒頭の例のような `false` な key を使う必要の無い普通のケースは 2 引数で書けるようにもしています。 ```ts -type ProjectId = Brand; // string & { readonly ProjectId: true }; +type PostId = Brand; // string & { readonly PostId: true }; ``` これを使うと数値型を以下のように実装できます。 @@ -384,51 +386,47 @@ type ProjectId = Brand; // string & { readonly ProjectId: t ```ts import { type Brand } from './brand'; -export type NaNType = Brand; +type NaNType = Brand; -export type FiniteNumber = Brand; +type FiniteNumber = Brand; -export type InfiniteNumber = Brand; +type InfiniteNumber = Brand; -export type POSITIVE_INFINITY = Brand< +type POSITIVE_INFINITY = Brand< number, 'NonNegative', 'Finite' | 'NaN' | 'Zero' >; -export type NEGATIVE_INFINITY = Brand< +type NEGATIVE_INFINITY = Brand< number, never, 'Finite' | 'NaN' | 'NonNegative' | 'Zero' >; -export type NonZeroNumber = Brand; +type NonZeroNumber = Brand; -export type NonNegativeNumber = Brand; +type NonNegativeNumber = Brand; -export type PositiveNumber = Brand; +type PositiveNumber = Brand; -export type NegativeNumber = Brand< - number, - never, - 'NaN' | 'NonNegative' | 'Zero' ->; +type NegativeNumber = Brand; -export type Int = Brand; +type Int = Brand; -export type Uint = Brand; +type Uint = Brand; -export type NonZeroInt = Brand; +type NonZeroInt = Brand; -export type SafeInt = Brand; +type SafeInt = Brand; -export type SafeUint = Brand< +type SafeUint = Brand< number, 'NonNegative' | 'Finite' | 'Int' | 'SafeInt', 'NaN' >; -export type NonZeroSafeInt = Brand< +type NonZeroSafeInt = Brand< number, 'Finite' | 'Int' | 'SafeInt', 'NaN' | 'Zero' diff --git a/articles/typescript-range-type.md b/articles/typescript-range-type.md index 7c65290b99..82ccf27a22 100644 --- a/articles/typescript-range-type.md +++ b/articles/typescript-range-type.md @@ -1,13 +1,11 @@ --- -title: 'TypeScript で連番の配列に詳しい型を付ける' +title: '[型パズル]TypeScript で連番の配列に詳しい型を付ける' emoji: '🐈' type: 'tech' # tech: 技術記事 / idea: アイデア -topics: ['typescript'] -published: false +topics: ['typescript', 'type-challenges'] +published: true --- -[TypeScript Playground WIP](https://www.typescriptlang.org/play?#code/C4TwDgpgBAqglgO2ADigXigSQQEwgDwB4AmAVgDYA+AbgChaAzAVwQGNg4B7BKAJwEMEAcwgBBXgJCEAylALAIuAM6xEKADRQAonPwLlqpMkoAKWlChLg-XsABcUaevNzcDrc4tWIYAPwOARloASgcAJUERABk4KxlNLRp6ZjYObj5IsQl+EDMva1sHBCYAWwAjCF5PVxwi0oqql28-OvLKkIdeCH4cbgAbEChitt4AbQBdOkYWdi4eAWEsyTzLAvsh+srqxVqNkerm1ob0KCDQvm7ehAG9homoAG8XLuAmXh5xSQA6Bl5OEpMCxEJisNmAmh2mmawWCdAAvskZmkEAAqDKLFagwq3LYuHZHXH5HwE3gnM4OADiikq-GAnF4hGGDU0ADdOHAcJoWABrBCcADuCEojxcDHpUBMfQgwCgcBJJyxwGoqx8UGFAAYoL5ZVBCDUoA44GqasqjQBqDDQkUWCwgOAQPo4WV0CwIhG0VjcKwXJRMPrAAKdS79QajAKaYiaADMmgALOMTkCljkTOGoKRYVAAPRZqCcbker2cKVfPqcISAiC+-0BTM5qBhiPRuPjeiehDelQYYhQfgqAJQAA+UB7w6jdHb3ugGHIvZUpCHUFnw4A7BOvTKutXgMQgz0QxKB8PR1Ao4vY4uF8PyMF7hgk58U0oIbDCx3ixBS+XK9viHXc6MkannGmikK29BZmioCQLKJRgFKJSKNYyJQCiWb0NB0ARIsMRxLI8g7Co8BGAkuj6DgRFqMYJwAEKcMWADyDIuHRjEMpgSgAHIQCylQyJQmgcdxvEMokAnZrmTjaFAgB2DIAIgyAFoMgAxDIA0gyADIMUCaoAgZGAAS+MlQCwcyyYALBqABAqLH0X0TGEBxMAIHM-GCUodkOWJFj1lJOjycp6lqmgPa6fphnpDJZm0MKBEGMAvBMBALjal0+7XIMWj4KwfRMHghBRFoACKoxaOMmg5flACyiD8eMlD3PWwU8IA1gyAH-agDqDIAZgyOBAACOUCAOYMgDR6oAEFGAOnegCaDIA0QyABYMgCADIACr6ADnmamAMbWzWAPIMgBWDHJMk9VNgC6DDJgB7aoA-gkyYA+gyAIEMLgONI3JwGA8QdZ1hBiVMtCYVArFWQyohkYRUBlJZ3QIJoNE-QY-0foIwoYKMogJpFFENgw-B9EoECthY2qjDRcN6L9oxIyjaPxVABOo5dUDRbF5OUxAUxvUJPF8QAKlDDZMzj5EqKMCCM7wCbajTBok8jqN0+A0C2fZ3CEDArMAPqS3MmBwX0MtJLQkFogAAmolQIMjqHoW9CvOVLCDK-BMuaAA0qDCMwCcssnKMMAc3jPMiejWrC4T5O2-DKg8nygrEwzIlPWlGVZTANuUBFuNRTFcUYz7ZMWA4NPkx7lRizB5UIIQnF25RRjy-nFuq5xmgTOrxvlyrhAuEXAc4o0FhM8XFxJTcQcCggEzhScYd8Zx8ecxTSfE9nvDk0zowAORSsIwAABbz27BiccTc+L4oQir+v5Ny-XltVw26qaF8V-s7X4tQCVJxPBYiVXDcoxF4ghgoOMDjYHgRCj3hLnCWuACCF07kySorM-4EAYgwJmTB4IQEIKVfg3IIAIKQYQXugpNCj1vjBGB+A4GYKlIQDuLcX4Hhwf3KqJwFagOIfAxBUoK7kPVprKAOskB6wNmhV6d8GH-xISwiAbCXAUITgjKhyUDIIF5H3CY1RbYYHQSATgDAoBM0HhgDiAAxOA+AIA4CiHvVeuFgDsM7pnFO-spEqDURorRxM7HjwAAYABIHiQN4HCNx29OCcU2Aya2lAs683CSJLOwTgFYCUAYoxJizErwseQzuMie7yODrQqGLgfGdx3kvfea9+ap2gBnJOsSmaBOCTITunjvHBL8azfC9ioANMQAwSoUBm5tJ8X44mW905DF5rE1B6DSHIKZngiBwSy5oIwaI7ACh3giy+OMxZSDxHt2qA0ziAyOEom1rrVZfRDa0H1ohJQYB+CsGgMfBZkzlm8JRtaCmd8AAicAhBwBlBgTUw4jwjkXGeYcF5hxXiXIuFci5UDDgAJxTAsG9Jm-A4Cq0kePKwvBEBCFZpi36DSvk-OAHCLxnTukwD8d7B2RRRn0GRXfAxvA4gEoMNi3F+L6nkoQF00kVKvGovRew6l2paUjJEki95MFiW-OkNFE4RLvm-L8VKlF6LkEuHbuk4MsiaFKK1T0zusqZTDhNfK0kw554BHVPPRcNqXCs1GIajJoYiout1W-K+Xx2bVGfp60M3rplQCDe6m0XdX6BuvpfaNIbr5hptK6hsQaY0+tTcG0NfqI0HlGCmuNab80ZtjZmj13co0FrzZW4t1afUJv9WW5NNai0Vqbem9Ndbs2yNza2wtbbe39qrbWrNSbu0trHc2idfbB0To7SO6dU6e3zoHU2r24x36TAZboMA9IZRvQ2U8huhrpmGt6Vi6KuKs0AA0dUNv1QmaGXtWYcuEJ3IZNptTT0NQ4U9v157z0NdqS9X6oDMriKPTuFL+UAdgMa5VwALXQf3UshuwahWV3EnO6+GrCCjF9bAcSQbsOXs0DaygVVgOfvDXSyVtB3RvWkF1cBLcfHywY51NhgCIJHK4Sc-WZz+HGzY9so1zHgnVDZdIgNciFGCjvFAJDWCaF4LCazJ+iapOjCNJ-Rxmj2aGm5Y0kYoqtE1JGIQOAwpqM5xcHCWJ11bpMb6WJrRN7I3SeyTXeh9mwDCarhI6oI7WyHOOTw055zBM3R84eiwP6DA+PE656hWTFEJrCIlvVyXZNBcfi4dUDgmYulOA4OW3mOOaDQ+wzQI6AASlxKv5rCFVeEuH0tv1KZqcIC8ikHw3gjIu2oOunA3RhO+tWehpMoVJu9mgPknGnlyybDbnUWEg1AYjLhvWVlvZl2hwRaBe0A+TD5VS0Wq2+ottzd7WbnbaYF4md3hk3fHiOpT+bVuNeJmEANhAwhhOGV9sthBRBJCAA) - ## 目標 以下の返り値型の `rangeArray` 関数を実装すること。 @@ -19,26 +17,30 @@ function rangeArray( step: number = 1 ): /* TODO */; -const result: readonly [1, 2, 3, 4] = range(1, 5); // ok +const result: readonly [1, 2, 3, 4] = rangeArray(1, 5); // ok console.log(result); // [1, 2, 3, 4] ``` -`start` と `end` が(型計算が重くならない)十分小さな非負整数で `start <= end` かつ `step = 1` が満たされる場合には、返り値に `number[]` ではなく `[1, 2, 3, 4]` というより詳しい型を付ける、というのが今回やりたいことです。 +`start` と `end` が(型計算が重くならない)十分小さな非負整数で `step = 1` が満たされる場合には、返り値に `number[]` ではなく `[1, 2, 3, 4]` のようなより詳しい型を付ける、というのが今回やりたいことです。 + +## 完成品 + +[TypeScript Playground](https://www.typescriptlang.org/play/?#code/C4TwDgpgBAqglgO2ADigXigSQQEwgDwB4AmAVgDYA+AbgChaAzAVwQGNg4B7BKAJwEMEAcwgBBXgJCEAylALAIuAM6xEKADRQAonPwLlqpMkoAKWlChLg-XsABcUaevNzcDrc4tWIYAPwOARloASgcAJUERABk4KxlNLRp6ZjYObj5IsQl+EBMrG3soBCYAWwAjCF5NRRwHYvLKzW8-OtKK3lCitsqAbQBdOkYWdi4eAWEsyTzrW1aGqtdarvmmhTA59vQoAM769v6oAG8XXghgJl4ecUkAOgZeThKTcZFpgurcVZ9g4LoAX2SwzSCAAVBkJmYvDNCntGi4ahs4VCfIjeFsgp0AOKKSr8YCcXiEWELABunDgOE0LAA1ghOAB3BCUI4uBgEqAmAA2ZygcFRW3ytmoljWUGZAAYoL5eVBCIsoA44GLFsKlQBqDDNYIsiwWEBwCCcnC8ugWAEA2jHCysbhWPgQJRMTnAAIOU78HDcTkgKA9AKaYiaADMmgALH0ti9JjkTP6oKRflAAPRJqCcakuG0IJScbk3TmcITPB1Ol2JlO+uOBqAhqDh2gAq1QLN2lQYYhQfgqAJQAA+UA7-aDpubtuAci25E7KlIfagU-7AHYRy3x6dHc7iG6IB6vT7Y3PBzW56G57P++RggcMFHrjGlNVfpnbbmIPnC8WN8BiOXUz1q7WoaaKQfQNvQSZgqAkC8iUYDciUijWMCUAgkm9BQdAAAq4AQFoACOhCiJoABCzIYCYhCYaY2poMymG6PoOAqKIUrbAqA7avINQqJR1HoHRDHcVAxEuNKQQWA4xCiVAwC8EwEAuA4DD8JySgQIMq66JA7DYdBGCESRpguAA+qcnJ4qMDi6bhBFEcJzJcQYsnyaxABEaCuexrkAIQeSEDhkhS-FQCweAMIgEA4IMGFQBEEwxHEsiOUxhgaNogkGPARhkbAHCqTcxGcLmADyhIuDAeVKAVRWcqVhAVXA+WYEoAByEAkpUMiUJoDVNa17WdYk3XJqmTjpYAdgyACIMgBaDIAMQyANIMgAyDFAkqAIGRgAEvuNIUIKMUDjYALBqABAq5WVdVJWEr1VXNTAO3cF1PWnddt0IIQQ0jY4CR7TNC3LZQaAdhtW0sLtB3HQ5ehCc5CkWNKWj4KwnJMHghBRFoACKPRaH0mioxjl03AAsogXV9JQBwVsD6SANYMgB-2oA6gyAGYMjgQHhUCAOYMgDR6oAEFGAOnegCaDIA0QyABYMgCADIACr6ADnmi2AMbWdOAPIMgBWDJN41syLgC6DONgB7aoA-gnjYA+gyAIEMilQAlwDSNScBgPEzMEUNgwIPwCFKGA-CsNAl06u9YCnMAHCVAAtHAQh0qcLgxdZ+EGfZ6AuBYFFUSYNECclzGsT2kmcRDyixxyvGJ8F9Ep8JafsT+OfSlDOdKSpamDBYMWFbmLWcMAhEZSlZQ1TuTJbBHtmaFD4OMSole6qxymqdDupWThkd2RPalD5DclTzDMkr1XRQDbwddaQS44NzVdUsUXnevoIJHtyoZ-coIOV94QOdETno-KkXr9rw-xEDyvS9ORvY8K4AOnlAL+mgF4QD-ilD+rEYF1G3pvB+88a6QKvuveS5dQGzwIt-dBqD37ALXnArBkBI64IgVAkehDx4oM3ggBBY94EdR3vQCwBAwD7xkjhLA-VmG8S2D0TCEYi49HocwiMQCXLV0nrvdhnCYpPVGPVHKijuCYFgpyeqPUkhh24aohA6i4JaKgAAaTQTAFRvDOoWLQTA6UEDN5mKLiYzBjdOTN1bg-GAmgTGUEoHQ7esj8AcNsFw6CRMXotXMWoYwWwImGM0S1TQ-QdH124fEjRj8x5RKLsSTwupC5ZxSu6T0CBvTbVpAyBA-QXCWLanwlqlC8GYLEZURBPRXLcmEMAAAFq5YRRSVAtUwZhDpXShC9P6ZvDJRikm+nFJoG4SyhGpL3qE8OnAWrdEJElQZUAAAGAASQ4xI-j7LIjnXZw8DnHMQAwSoUAcl7NOfs1iUSmGVH+OhbhuMthNhKXuX0UTECpWQH0Bw2A8BEEaf8aKejcAEEIE865xIVEIvwAkm4kKCAJKRToh2TsXZuywOihJns5HrPhVC3FyKhKoq2Ni-AxUGCYSYHBCAhACb8GpBAVl7LCA0jpIyTQjTVkKPRcyvl3JKJoIBWUn0gqqkpIZRKllbLuS4qorvcVULJXqogLinOhTrlyvKYqxk-R8kWDMRgHlIBOAMFAbUhlSgABicB8CRSiIoCZPTTb8IIRgwBpi0F2odU64NTi9lHJOdss5mD8aYU2dswgviAnMPTW0xhyx2jar0W6j1XqfW9P9caoSpqFUIEqRa0mMcx7EjQaMzpxa+kSKgBA9iUMvm0HYRAHSOFCCMsIKQYakp+w9n7EeIMJ5TDuVck+WgMUuU8qlRyzCIq0H0owMu3l+rMU7tXZqzQMaWpnPxY7B0RLoAHr3Roz2MUAAiQc4DjgwGOtik65zTv7Kefs555xzkXHOVA-YACceboKYX4I1GVRcrC8EQEIe+aCY1PqEC+v4tyED3LRDAM5rEYDsVaSw3R0F3W8DiGWgw8HEPIaLjGu5Dy8PHKgzBqi+HpSEY+SRtJ0E0MvukLJLYqHn3ADORBrCjUOVGtlTuUpZqq1CuqdjHOtKDD8fHP2DTgm0T9lcgEcUnlx3imdRgHoOdLUWaWTcIRVrfTWfXVABzKmx49Ac4s5ZHmbMud1G5zzTn-PuYC95uzfmbNecc0FqLyyfMWDC5FwLiXwvBYSyFqzSXUuZYi9llLsX7MZZy9F5LRXMt5fi4VgrKWKvFYy2Vkr1WstVaa-V3LLg+g9BagMVhayD7pO5bu9lhqx7rtU2gmjwg7MAA1ZO7nlRUpT15fSgQsDlcbQg0HDODcRzeamUquVcpgybm9yNxEaWgxjuGE1oO07JTBN7BuZMc6xxJw14tScIII7GsBhoOfe5NzQBnKCk0zbwEH3be39sgJy-rq7CALPjK9+HSPNBI9JiYOdC6YrSBZkizd2ycrY7wjSzQ93pXmoQCKvxcLoKE6G48vHKwXBUeKXJwF5Oak5SbHFpUIKw2OqEYqFDxyXkEdOkmrZ8xCBwGZNxkcfxBgQ+ANZGQOOR3JPh1WYMYY0cY+p9AU25tLZZPp7k7Z+TmcqArfNpVoEcoG4tmATF9vLbE9AZoK3KT7YXudq7fXsQzYO7JU2ClvXoLO8d5k0bpvGfDZm-Jyt1blN2bCHHtnimbf5Lo3sj3y3dTSkwpvMIPQADk4zenF4GdczbeeI0gPD6757vFkkAAk5NN+C2EUmEmoCt49LB7PrO5vs6+w+rYxGs8msH+U8zY8LtQH+znazxZZsKcT-0YIbXDubwfd3xvJ8B8r4Twt2tGB9+T8P0tzBOfN5n-LVPn0PRydebn53zBYQb-g+CX2pXA7w8kGSZrjWGGMBKTMkoBCAbOh5E+EAA) -## 関数本体の実装 +## 解説 + +以降は実装の解説です。 + +### 関数本体の実装 型は後で付けるとして本体をまず実装します。 -実装方法は色々ありますが、以下のような実装をしておきます。 +実装方法は色々あり得ますが、以下のような実装をしておきます。 ```ts -function rangeArray( - start: number, - end: number, - step: number = 1, -): readonly number[] { - return Array.from(range(start, end, step)); // [...range(start, end, step)] でも ok +function rangeArray(start: number, end: number, step: number = 1): number[] { + return Array.from(range(start, end, step)); } function* range( @@ -64,9 +66,9 @@ for (const i of range(1, 5)) { // 4 ``` -## 型の実装 +### 型の実装 -`step = 1` (または省略されたとき)で `start` と `end` が型計算が再帰制限にひっかからない&重くならない程度に十分小さい場合のみ型を詳細化したいので、以下のように関数をオーバーロードして、引数の型に応じて詳細化した型 `RangeList` と条件を満たさない場合の緩い型 `readonly number[]` を出し分けるようにします。 +結果配列が大きすぎる場合、型計算が重くなったりそもそも再帰制限にひっかかって計算できなかったりするので、 `start` と `end` が十分小さい場合のみ型を詳細化することにします。以下のように関数をオーバーロードして、引数の型に応じて return type を詳細化した型 `RangeList` にするか `number[]` にするかが決まるようにします。 ```ts type Uint8 = Index<256>; // 0 | 1 | 2 | ... | 255 @@ -74,26 +76,15 @@ type Uint8 = Index<256>; // 0 | 1 | 2 | ... | 255 function rangeArray( start: S, end: E, - step?: 1 + step?: 1, ): RangeList; -function rangeArray( - start: number, - end: number, - step?: Uint8 -): readonly number[]; - -function rangeArray( - start: number, - end: number, - step?: Uint8 = 1 -): readonly number[] { - return Array.from(range(start, end, step)); +function rangeArray(start: number, end: number, step?: number): number[]; ``` -`Index` 型ユーティリティも今回実装しますが、これに関してはべた書きでも良いかもしれません。 +`Uint8` 型はべた書きでも良いかもしれませんが、 `Index` という型ユーティリティを実装すれば `Index<256>` として実装できます。詳しくは[TypeScript 型ユーティリティ集](https://zenn.dev/noshiro_piko/articles/typescript-type-utilities)を参照してください。 -なお、 `start: S` と `end: E` には 1 個の非負整数ではなく union 型が渡される可能性もありますが、その場合には結果と厳密一致する型を生成しづらい(※)ため、その場合には `S` の最小値から `E` の最大値までの整数の union を要素とする長さ不定の配列型を返り値とするという要件もここで追加しておきます(※:一応 `S = 1 | 2`, `E = 3 | 4` に対して `[1, 2] | [1, 2, 3] | [2] | [2, 3]` という感じで全パターンの union 型を返すように定義できる可能性はありますが、 $|S| \times |E|$ のサイズの union になり型計算が重くなる上に見づらくなりあまり嬉しくもないのでこれは避けます。)。 +`start: S` や `end: E` には 1 個の非負整数ではなく `Uint8` の部分型の union 型(例: `1 | 2 | 3`)が渡される可能性もありますが、その場合には `S` の最小値から `E` の最大値までの整数の union を要素とする長さ不定の配列型を返り値とするという要件もここで追加しておきます[^union-arg]。 ```ts const s = 2 as 1 | 2 | 3; @@ -102,87 +93,157 @@ const result2: readonly (1 | 2 | 3 | 4 | 5 | 6)[] = rangeArray(s, e); console.log(result2); // [2, 3, 4, 5] ``` -これは以下のように引数にユーザー定義の数値の union 型の変数を渡す場合に、`rangeArray` の結果の型を不必要に広げないためです。 +[^union-arg]: 一応 `S = 1 | 2`, `E = 3 | 4` なら `[1, 2] | [1, 2, 3] | [2] | [2, 3]` という感じで全パターンの union 型を返すように定義できる可能性はありますが、 $|S| \times |E|$ のサイズの union になり型計算が重くなる上に結果の型も見づらくあまり嬉しくなさそうなので、これは実装しないことにします。 + +これは以下のように引数にユーザー定義の数値の union 型の変数を渡す場合にも対応するためです。 ```ts -type MonthEnum = Exclude, 0>; -type DateEnum = Exclude, 0>; +type MonthEnum = Exclude, 0>; // 1 | 2 | ... | 12 +type DateEnum = Exclude, 0>; // 1 | 2 | ... | 31 + +const getAllDatesOfMonth = (year: number, month: MonthEnum): DateEnum[] => + rangeArray( + 1, + (getLastDateNumberOfMonth(year, month) + 1) as 29 | 30 | 31 | 32, + ) satisfies DateEnum[]; const getLastDateNumberOfMonth = ( year: number, month: MonthEnum, ): 28 | 29 | 30 | 31 => new Date(year, month, 0).getDate() as 28 | 29 | 30 | 31; -const getAllDatesOfMonth = ( - year: number, - month: MonthEnum, -): readonly DateEnum[] => - rangeArray( - 1, - (getLastDateNumberOfMonth(year, month) + 1) as 29 | 30 | 31 | 32, - ).map( - (date: DateEnum) => new Date(year, month - 1, date).getDate() as DateEnum, - ); - console.log(getAllDatesOfMonth(2024, 2)); // [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29] ``` 次節以降で `RangeList` 型を実装していきます。 -## `RangeList` の本体 +#### `RangeList` -`RangeList` の本体は以下のような実装になります。 +`RangeList` の本体は以下の実装になります。 ```ts type RangeList = - BoolOr< - BoolOr, IsNever>, // S, E のいずれかが 0 要素の union の場合 - BoolOr, IsUnion> // S, E のいずれかが >=2 要素の union の場合 - > extends true - ? readonly Exclude]>[] // union に対して Seq で型計算すると、結果が正しくないので、その回避のため - : Skip>; + // S, E のいずれかが 2 要素以上の union の場合 + BoolOr, IsUnion> extends true + ? Exclude]>[] // union に対して Seq で型計算すると、結果が正しくならないので、その回避のため + : ListSkip>; +``` + +ユニットテスト + +```ts +expectType, readonly [1, 2, 3, 4]>('='); +expectType, readonly [1]>('='); +expectType, readonly []>('='); +expectType, readonly (1 | 2)[]>('='); +expectType, readonly (1 | 2 | 3 | 4)[]>('='); +expectType, readonly (1 | 2 | 3 | 4 | 5 | 6)[]>( + '=', +); +expectType, readonly []>('='); ``` -- `Skip>` の部分は `S` と `E` がちょうど 1 要素の非負整数(0 以上 255 以下)の場合に対応しています - - 例: `RangeList<1, 5> = readonly [1, 2, 3, 4]` -- `readonly Exclude]>[]` の部分はそれ以外の場合に対応しています。 - - 例: `RangeList<1 | 2 | 3, 5 | 6 | 7> = readonly (1 | 2 | 3 | 4 | 5 | 6)[]` -- `IsNever` は `U` が 0 要素の union ( = `never`)であるとき `true`、そうでなければ `false` に評価される型を返します。 -- `IsUnion` は `U` が 2 要素以上の union 型であるとき `true`、そうでなければ `false` に評価される型を返します。 - - 例: `IsUnion<1 | 2> = true`, `IsUnion<1> = false`, -- `LEQ[U]` は `U` の最大値未満のすべての非負整数を含む union 型を返します。 - - 例: `LEQ[4 | 5 | 6] = 0 | 1 | 2 | 3 | 4 | 5` -- `Min` は `U` の最小の非負整数を返します。 - - 例: `Min<3 | 4 | 5> = 3` -- これらを組み合わせた `Exclude]>` という部分は、 `S = 1 | 2 | 3`, `E = 5 | 6 | 7` に対して `1 | 2 | 3 | 4 | 5 | 6` となる型です。 -- `Seq` は `N` までの非負整数の連番のタプル型を返します。 - - 例: `Seq<3> = readonly [0, 1, 2]` -- `Skip` はタプル型 `T` の先頭の `N` 要素を除いた型を返します。 - - 例: `Skip<2, [1, 2, 3]> = [3]` +`expectType` はここでは `expect("=")` が `A` と `B` は等しい型であることを確認する型レベルの assert 文を表しています。型ユニットテスト用ユーティリティ `expectType` の詳細については[TypeScript 型ユーティリティ集](https://zenn.dev/noshiro_piko/articles/typescript-type-utilities)を参照してください。 -以上のような型をそれぞれ実装できれば所望の型 `RangeList` が実装できることが確認できると思います。 -使っている型 `BoolOr`, `IsNever`, `IsUnion`, `LEQ`, `Min`, `Skip`, `Seq` という型の実装を次節以降それぞれ説明していきます。 +`ListSkip>` の部分は `S` と `E` がちょうど 1 要素の非負整数(0 以上 255 以下)の場合の型です。 -## `Min`, `BoolOr`, `IsNever`, `IsUnion` の実装 +`ListSkip` はタプル型 `T` の先頭 `N` 要素を除いた型を返す型です(例:`ListSkip<2, [1, 2, 3, 4, 5]>` = `[3, 4, 5]`)。 +`Seq` は整数 `N` までの連番配列を返す型です(例: `Seq<5>` = `[0, 1, 2, 3, 4]`。 +これらを組み合わせると `ListSkip>` とすることで `S` 以上 `E` 未満の整数の連番配列を作ることができます(例: `RangeList<1, 5> = [1, 2, 3, 4]`)。よって、 `ListSkip` と `Seq` を作ることができればここは解決します。 -`Min` の実装はこの記事で紹介しています。 -https://zenn.dev/noshiro_piko/articles/typescript-type-level-min +次に `S` または `E` が union 型の場合の判定ですが、これは `BoolOr` と `IsUnion` という型ユーティリティを実装することで実現できます。実装は [TypeScript 型ユーティリティ集](https://zenn.dev/noshiro_piko/articles/typescript-type-utilities)を参照してください。 +そしてそのときの型は、 union 型 `S` の中の最小値 `L` から union 型 `E` の中の最大値 - 1 = `U` までの union 型を要素とする配列 `(L | (L + 1) | ... | (U - 1) | U)[]` とします。これは `LEQ` という配列を作ることで `Exclude]>[]` として実装することができます。 +`LEQ[U]` は `U` の最大値未満のすべての非負整数を含む union 型を返すようにします(例: `LEQ[4 | 5 | 6] = 0 | 1 | 2 | 3 | 4 | 5`)。 `Min` は非負整数の union 型 `U` を受け取り、最小値を返す型です(例: `Min<3 | 4 | 5> = 3`)。`Min` の実装については[TypeScript の型ユーティリティ Min, Max の実装](https://zenn.dev/noshiro_piko/articles/typescript-type-level-min)という記事で解説しているのでそちらを参照してください。あとは `LEQ` を実装できれば OK です。 -`BoolOr` は型の論理和を取る関数で、[ここ](https://zenn.dev/link/comments/0c6cc10b5889f2)に実装を載せています。 +--- -`IsNever` は型が `never` かどうか判定する関数で、[ここ](https://zenn.dev/link/comments/914c745a18d71c)に実装を載せています。 +#### `ListSkip` の実装 + +以下の実装になります。 + + +```ts +type ListSkip = + ListSkipImpl; + +type ListSkipImpl< + N extends number, + T extends readonly unknown[], + R extends readonly unknown[], +> = T extends readonly [] + ? T + : R['length'] extends N + ? T + : ListSkipImpl, [Head, ...R]>; +``` -`IsUnion` は型が union 型かどうか判定する関数で、[ここ](https://zenn.dev/link/comments/d108fe1394e4f4)に実装を載せています。 +`Head` は `T` の先頭要素を取り出す型です。これは `infer` を使って以下で実装できます。 -## `LEQ` の実装 +```ts +type Head = T extends readonly [ + infer X, + ...(readonly unknown[]), +] + ? X + : D; +``` -## `Skip` の実装 +`Tail` は `T` の先頭要素を除いた残りの配列を返す型です。以下で実装できます。 -## `Seq` の実装 +```ts +type Tail = A extends readonly [] + ? readonly [] + : A extends readonly [unknown, ...infer R] + ? R + : A; +``` ---- +`ListSkipImpl` は、`T` を先頭要素 `H` と残りに分け、 `T` = `Tail`, `R` = `[Head, ...R]` と更新して再帰呼び出しし、 `R` の長さが `N` に到達した時点で `R['length'] extends N` という条件が満たされるので `T` が返される、という動作になっています。 -## 完成品 +``` +ListSkip<2, [1, 2, 3, 4, 5]> +-> ListSkipImpl<2, [1, 2, 3, 4, 5], []> +-> ListSkipImpl<2, [2, 3, 4, 5], [1]> +-> ListSkipImpl<2, [3, 4, 5], [2, 1]> +-> [3, 4, 5] +``` + +#### `Seq` の実装 + +```ts +type Seq = SeqImpl>; + +type SeqImpl = { + [i in keyof T]: i extends `${number}` ? ToNumber : never; +}; + +type ToNumber = S extends `${infer N extends number}` + ? N + : never; +``` + +`MakeTuple` は`N` 要素の `E` からなるタプルを返す型で、[TypeScript 型ユーティリティ集](https://zenn.dev/noshiro_piko/articles/typescript-type-utilities)で実装方法を解説しています。 +`Seq` は、長さ `N` の適当な配列を作り、 `keyof` でその key を取り出すことで連番配列を作っています。このとき `keyof` の結果は文字列リテラル型になってしまうのでこれを数値型にするために `ToNumber` を使っています。 + +#### `LEQ` の実装 + +```ts +type LEQ = { + [N in Uint8]: Index; +}; +// { +// 0: never; +// 1: 0; +// 2: 0 | 1; +// 3: 0 | 1 | 2; +// 4: 0 | 1 | 2 | 3; +// 5: 0 | 1 | 2 | 3 | 4; +// 6: 0 | 1 | 2 | 3 | 4 | 5; +// ... +// } +``` + +で実装できます。 -[TypeScript Playground](https://www.typescriptlang.org/play?#code/C4TwDgpgBAqglgO2ADigXigSQQEwgDwB4AmAVgDYA+AbgChaAzAVwQGNg4B7BKAJwEMEAcwgBBXgJCEAylALAIuAM6xEKADRQAonPwLlqpMkoAKWlChLg-XsABcUaevNzcDrc4tWIYAPwOARloASgcAJUERABk4KxlNLRp6ZjYObj5IsQl+EDMva1sHBCYAWwAjCF5PVxwi0oqql28-OvLKkIdeCH4cbgAbEChitt4AbQBdOkYWdi4eAWEsyTzLAvsh+srqxVqNkerm1ob0KCDQvm7ehAG9homoAG8XLuAmXh5xSQA6Bl5OEpMCxEJisNmAmh2mmawWCdAAvskZmkEAAqDKLFagwq3LYuHZHXH5HwE3gnM4OADiikq-GAnF4hGGDU0ADdOHAcJoWABrBCcADuCEojxcDHpUBMfQgwCgcBJJyxwGoqx8UGFAAYoL5ZVBCDUoA44GqasqjQBqDDQkUWCwgOAQPo4WV0CwIhG0VjcKwXJRMPrAAKdS79QajAKaYiaADMmgALOMTkCljkTOGoKRYVAAPRZqCcbker2cKVfPqcISAiC+-0BTM5qBhiPRuPjeiehDelQYYhQfgqAJQAA+UB7w6jdHb3ugGHIvZUpCHUFnw4A7BOvTKutXgMQgz0QxKB8PR1Ao4vY4uF8PyMF7hgk58U0oIbDCx3ixBS+XK9viHXc6MkannGmikK29BZmioCQLKJRgFKJSKNYyJQCiWb0NB0ARIsMRxLI8g7Co8BGAkuj6DgRFqMYJwAEKcMWADyDIuHRjEMpgSgAHIQCylQyJQmgcdxvEMokAnZrmTjaFAgB2DIAIgyAFoMgAxDIA0gyADIMUCaoAgZGAAS+MlQCwcyyYALBqABAqLH0X0TGEBxMAIHM-GCUodkOWJFj1lJOjycp6lqmgPa6fphnpDJZm0MKBEGMAvBMBALjal0+7XIMWj4KwfRMHghBRFoACKoxaOMmg5flACyiD8eMlD3PWwU8IA1gyAH-agDqDIAZgyOBAACOUCAOYMgDR6oAEFGAOnegCaDIA0QyABYMgCADIACr6ADnmamAMbWzWAPIMgBWDHJMk9VNgC6DDJgB7aoA-gkyYA+gyAIEMLgONI3JwGA8QdZ1hBiVMtCYVArFWQyohkYRUBlJZ3QIJoNE-QY-0foIwoYKMogJpFFENgw-B9EoECthY2qjDRcN6L9oxIyjaPxVABOo5dUDRbF5OUxAUxvUJPF8QAKlDDZMzj5EqKMCCM7wCbajTBok8jqN0+A0C2fZ3CEDArMAPqS3MmBwX0MtJLQkFogAAmolQIMjqHoW9CvOVLCDK-BMuaAA0qDCMwCcssnKMMAc3jPMiejWrC4T5O2-DKg8nygrEwzIlPWlGVZTANuUBFuNRTFcUYz7ZMWA4NPkx7lRizB5UIIQnF25RRjy-nFuq5xmgTOrxvlyrhAuEXAc4o0FhM8XFxJTcQcCggEzhScYd8Zx8ecxTSfE9nvDk0zowAORSsIwAABbz27BiccTc+L4oQir+v5Ny-XltVw26qaF8V-s7X4tQCVJxPBYiVXDcoxF4ghgoOMDjYHgRCj3hLnCWuACCF07kySorM-4EAYgwJmTB4IQEIKVfg3IIAIKQYQXugpNCj1vjBGB+A4GYKlIQDuLcX4Hhwf3KqJwFagOIfAxBUoK7kPVprKAOskB6wNmhV6d8GH-xISwiAbCXAUITgjKhyUDIIF5H3CY1RbYYHQSATgDAoBM0HhgDiAAxOA+AIA4CiHvVeuFgDsM7pnFO-spEqDURorRxM7HjwAAYABIHiQN4HCNx29OCcU2Aya2lAs683CSJLOwTgFYCUAYoxJizErwseQzuMie7yODrQqGLgfGdx3kvfea9+ap2gBnJOsSmaBOCTITunjvHBL8azfC9ioANMQAwSoUBm5tJ8X44mW905DF5rE1B6DSHIKZngiBwSy5oIwaI7ACh3giy+OMxZSDxHt2qA0ziAyOEom1rrVZfRDa0H1ohJQYB+CsGgMfBZkzlm8JRtaCmd8AAicAhBwBlBgTUw4jwjkXGeYcF5hxXiXIuFci5UDDgAJxTAsG9Jm-A4Cq0kePKwvBEBCFZpi36DSvk-OAHCLxnTukwD8d7B2RRRn0GRXfAxvA4gEoMNi3F+L6nkoQF00kVKvGovRew6l2paUjJEki95MFiW-OkNFE4RLvm-L8VKlF6LkEuHbuk4MsiaFKK1T0zusqZTDhNfK0kw554BHVPPRcNqXCs1GIajJoYiout1W-K+Xx2bVGfp60M3rplQCDe6m0XdX6BuvpfaNIbr5hptK6hsQaY0+tTcG0NfqI0HlGCmuNab80ZtjZmj13co0FrzZW4t1afUJv9WW5NNai0Vqbem9Ndbs2yNza2wtbbe39qrbWrNSbu0trHc2idfbB0To7SO6dU6e3zoHU2r24x36TAZboMA9IZRvQ2U8huhrpmGt6Vi6KuKs0AA0dUNv1QmaGXtWYcuEJ3IZNptTT0NQ4U9v157z0NdqS9X6oDMriKPTuFL+UAdgMa5VwALXQf3UshuwahWV3EnO6+GrCCjF9bAcSQbsOXs0DaygVVgOfvDXSyVtB3RvWkF1cBLcfHywY51NhgCIJHK4Sc-WZz+HGzY9so1zHgnVDZdIgNciFGCjvFAJDWCaF4LCazJ+iapOjCNJ-Rxmj2aGm5Y0kYoqtE1JGIQOAwpqM5xcHCKYnDuErL4+cwTN0wDCZ-QYHx4mb2Ruk9kg1Fgwg+eoVkxRrZVMuHVA4JmLpTgODltdW6HHNBofYZoEdAAJS4aX81hCqvCXDwWu2lM1OEBeRSD4bwRkXbUpXTgboETBRLd0PMIy81oormSZM5Poc19z4nqgjvC7ErLPQ0mUKk3ezQHyTjTy5RNhtzqLCQagMRlw3rKy3tC7J8YwRaBe0A+TD5VS0Wq2+gt3zd7WbnbaUN4md3hk3fHiOpT+aVt5eJmEANhAwhhOGV9sthBRBJCAA) +`Index` の実装は[TypeScript 型ユーティリティ集](https://zenn.dev/noshiro_piko/articles/typescript-type-utilities)を参照してください。 diff --git a/articles/typescript-type-level-min.md b/articles/typescript-type-level-min.md index 0fbc078182..590c6b6977 100644 --- a/articles/typescript-type-level-min.md +++ b/articles/typescript-type-level-min.md @@ -1,8 +1,8 @@ --- -title: 'TypeScript の型ユーティリティ Min, Max の実装' +title: '[型パズル]TypeScript の型ユーティリティ Min, Max の実装' emoji: '🐈' type: 'tech' # tech: 技術記事 / idea: アイデア -topics: ['typescript'] +topics: ['typescript', 'type-challenges'] published: true --- @@ -15,8 +15,8 @@ published: true type Min = /* TODO */; type Max = /* TODO */; -type R1 = Max<1 | 2 | 3>; // 3 -type R2 = Min<1 | 2 | 3>; // 1 +type T1 = Max<1 | 2 | 3>; // 3 +type T2 = Min<1 | 2 | 3>; // 1 ``` ## Min の実装 @@ -24,12 +24,12 @@ type R2 = Min<1 | 2 | 3>; // 1 `Min` は以下のコードで実装できます。 ```ts -type _MinImpl< +type Min = MinImpl; + +type MinImpl< N extends number, T extends readonly unknown[], -> = T['length'] extends N ? T['length'] : _MinImpl; - -export type Min = _MinImpl; +> = T['length'] extends N ? T['length'] : MinImpl; ``` (簡単な解説) @@ -39,9 +39,9 @@ TypeScript の型レベルプログラミングで非負整数に関する処理 ``` Min<1 | 2 | 3> --> _MinImpl<1 | 2 | 3, []> --> _MinImpl<1 | 2 | 3, [0]> // T["length"] (= 0) extends 1 | 2 | 3 は false なので再帰 --> 1 // T["length"] (= 1) extends 1 | 2 | 3 は true なので [0]["length"] = 1 を返す +-> MinImpl<1 | 2 | 3, []> +-> MinImpl<1 | 2 | 3, [0]> // T["length"] (= 0) extends 1 | 2 | 3 は false なので再帰 +-> 1 // T["length"] (= 1) extends 1 | 2 | 3 は true なので [0]["length"] = 1 を返す ``` ## Max の実装 @@ -50,12 +50,12 @@ Min<1 | 2 | 3> ```ts -type _MaxImpl +export type Max = MaxImpl; + +type MaxImpl = [N] extends [Partial["length"]] ? T["length"] - : _MaxImpl; - -export type Max = _MaxImpl; + : MaxImpl; ``` https://www.typescriptlang.org/play?#code/FAFwngDgpgBA+gWQIYA8CSBbCAbAPAORihRCgDsATAZxjIFcMAjKAJwBoYAVIk86mFlCQUA9mWxgYdMgGsyIgO5kA2gF0YAXhhqAfJu351xUpRrKACkhYgAlkjycdygETZyAcxAALZ6vUB+Lhc3Mk8fdQAueGR0LDx8DmUABg4AOnTOVR1gYGIIEWsYcGgYGIIeE356JlY9LURUTBwCHQBuHOLYACUoKjpsEH1lYBhS1FwknTYRsZQJmAAfGABGRZgAJimZsvW1gBY1gDYt0eSOFJgLpNVgj29fadHLazs8M8vz86zb0PvVYH+wCAA @@ -82,26 +82,26 @@ type Index = Partial<[0, 0, 0, 0]>['length']; ``` Max<1 | 2 | 3> --> _MaxImpl<1 | 2 | 3, []> --> _MaxImpl<1 | 2 | 3, [0]> // [1 | 2 | 3] extends [Partial<[]>["length"]] (= [0]) は false なので再帰 --> _MaxImpl<1 | 2 | 3, [0, 0]> // [1 | 2 | 3] extends [Partial<[0]>["length"]] (= [0 | 1]) は false なので再帰 --> _MaxImpl<1 | 2 | 3, [0, 0, 0]> // [1 | 2 | 3] extends [Partial<[0, 0]>["length"]] (= [0 | 1 | 2]) は false なので再帰 --> 3 // [1 | 2 | 3] extends [Partial<[0, 0, 0]>["length"]] (= [0 | 1 | 2 | 3]) は true なので [0, 0, 0]["length"] を返す +-> MaxImpl<1 | 2 | 3, []> +-> MaxImpl<1 | 2 | 3, [0]> // [1 | 2 | 3] extends [Partial<[]>["length"]] (= [0]) は false なので再帰 +-> MaxImpl<1 | 2 | 3, [0, 0]> // [1 | 2 | 3] extends [Partial<[0]>["length"]] (= [0 | 1]) は false なので再帰 +-> MaxImpl<1 | 2 | 3, [0, 0, 0]> // [1 | 2 | 3] extends [Partial<[0, 0]>["length"]] (= [0 | 1 | 2]) は false なので再帰 +-> 3 // [1 | 2 | 3] extends [Partial<[0, 0, 0]>["length"]] (= [0 | 1 | 2 | 3]) は true なので [0, 0, 0]["length"] を返す ``` 補足ですが、 `[N] extends [Partial["length"]]` のところは "union distribution" という挙動を回避するために TypeScript の型レベルプログラミングでたびたび用いられるテクニックが使われています。 -`extends` の両辺を配列にくるまず `N extends Partial["length"]` としてしまうと、 `N` (例では `1 | 2 | 3`)が分配されてそれぞれ評価されてしまいます。 `[N]` や `N[]` とすることでこの挙動を回避して union 型 `N` の全体と `Partial["length"]` を直接比較することができます。 +`extends` の両辺を配列にくるまず `N extends Partial["length"]` としてしまうと、 `N` (例では `1 | 2 | 3`)が分配されてそれぞれ評価されてしまいます。 `[N]` または `N[]` とすることでこの挙動を回避して union 型 `N` の全体と `Partial["length"]` をそのまま比較することができます。 逆に union 型の各要素についてループ処理を書きたいときには `N extends N ? ... : never` のようにして union distribution を使うこともあります。 ちなみに、先ほど載せたリンクの [StackOverflow の記事](https://stackoverflow.com/questions/62968955/how-to-implement-a-type-level-max-function-over-a-union-of-literals-in-typescri)では同じ投稿者が再帰上限にひっかからないための実装の改良も載せていますが、元実装 ```ts -type _MaxImpl = { +type MaxImpl = { b: T['length']; - r: _MaxImpl; + r: MaxImpl; }[[N] extends [Partial['length']] ? 'b' : 'r']; -export type Max = _MaxImpl; +export type Max = MaxImpl; type Result = Max<1 | 2 | 512>; // Type instantiation is excessively deep and possibly infinite. ts(2589) @@ -111,12 +111,12 @@ type Result = Max<1 | 2 | 512>; ```ts -type _MaxImpl +type MaxImpl = [N] extends [Partial["length"]] ? T["length"] - : _MaxImpl; + : MaxImpl; -export type Max = _MaxImpl; +export type Max = MaxImpl; type Result1 = Max<1 | 2 | 512>; // ok type Result2 = Max<1 | 2 | 1024>; // Type instantiation is excessively deep and possibly infinite. ts(2589) diff --git a/articles/typescript-type-utilities.md b/articles/typescript-type-utilities.md index 3443e053a2..079b5d17c6 100644 --- a/articles/typescript-type-utilities.md +++ b/articles/typescript-type-utilities.md @@ -1,13 +1,12 @@ --- -title: 'TypeScript 型ユーティリティ集' +title: '[型パズル]TypeScript 型ユーティリティ集' emoji: '🐈' type: 'tech' # tech: 技術記事 / idea: アイデア -topics: ['typescript'] +topics: ['typescript', 'type-challenges'] published: true --- -ほぼ私のライブラリ [ts-type-utils](https://github.com/noshiro-pf/mono/tree/main/packages/ts-type-utils) からの抜粋です(都合により TypeScript 標準ライブラリ(`lib.es5.d.ts` 等)に依存するかどうかでパッケージを分けています)。 -随時更新していきます。 +ほぼ私のライブラリ [ts-type-utils](https://github.com/noshiro-pf/mono/tree/main/packages/ts-type-utils) からの抜粋です。 --- @@ -15,17 +14,26 @@ published: true ```ts expectType<[1, 2, 3], [1, 2, 3]>('='); -expectType<[any], [number]>('<='); expectType('!='); expectType('!='); + +// @ts-expect-error +expectType<{ x: 1 } & { y: 2 }, { x: 1; y: 2 }>('='); + +expectType<{ x: 1 } & { y: 2 }, { x: 1; y: 2 }>('~='); + +expectType<{ x: 1 } & { y: 2 }, { x: 1; y: 2 }>('<='); + +expectType<{ x: 1 } & { y: 2 }, { x: 1; y: 2 }>('>='); ``` 型の等価性や部分型関係をチェックするユーティリティです。 -- `expectType("=")` は型 `A` と型 `B` が等価であるときに型エラーにならず、そうでないときに型エラーになります。 -- `expectType("!=")` は `"="` の逆です。 -- `expectType("<=")` は型 `A` が型 `B` の部分型であるとき型エラーにならず、部分型でないときに型エラーになります。 -- `expectType("~=")` は `A` が `B` の部分型かつ `B` が `A` の部分型であるときに型エラーにならず、そうでないときに型エラーになります。 `"="` の内部実装の `TypeEq` は例えば `{ x: 1 } & { y: 2 }` と `{ x: 1; y: 2 }` を区別してしまう程厳密なものなので、もう少し緩い等価判定を行いたい場合に用意しています。 +- `expectType("=")` : 型 `A` と型 `B` が等しいかどうか +- `expectType("!=")` : 型 `A` と型 `B` が等しくないかどうか +- `expectType("<=")` : 型 `A` が型 `B` の部分型であるかどうか +- `expectType("~=")` : `A` が `B` の部分型かつ `B` が `A` の部分型であるかどうか + - `"="` の内部実装の `TypeEq` は例えば `{ x: 1 } & { y: 2 }` と `{ x: 1; y: 2 }` を区別してしまう程厳密なものなので、もう少し緩い等価判定を行いたい場合に用意しています。 --- 実装 --- @@ -41,7 +49,7 @@ expectType('!='); * - `expectType("!>=")` passes if `B` doesn't extend `A`. * - `expectType("!=")` passes if `A` is not equal to `B`. */ -export const expectType = ( +const expectType = ( _relation: TypeEq extends true ? '<=' | '=' | '>=' | '~=' : @@ -121,11 +129,11 @@ type BoolAnd = `A` や `B` に `true`, `false` の他に `boolean` や `never`, `any` などが入ってくる可能性もあるため、 `TypeEq` で厳密一致するかどうかをチェックする実装にしています。 `true` か `false` になっていなければすべて `never` を返します。 :::details ソースコード(残りの実装) -https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/src/boolean.ts +https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/src/boolean.d.mts ::: :::details 使用例(ユニットテスト) -https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/test/boolean.ts +https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/test/boolean.mts ::: ## `IsNever` @@ -149,7 +157,7 @@ type IsNever = [T] extends [never] ? true : false; ::: :::details テスト -https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/test/is-never.ts +https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/test/is-never.mts ::: ## `IsUnion` @@ -164,22 +172,21 @@ expectType, true>('='); Type Challenges^[[Type Challenges (IsUnion)](https://github.com/type-challenges/type-challenges/blob/main/questions/01097-medium-isunion/README.md)]にも掲載されています。 ```ts -type IsUnion = _IsUnionImpl; +type IsUnion = IsUnionImpl; -/** @internal */ -type _IsUnionImpl = +type IsUnionImpl = IsNever extends true ? false : K extends K ? BoolNot> : never; ``` -まず与えられた型 `U` が `never` であれば `false` を返します。 +まず引数の型 `U` が `never` であれば `false` を返します。 次に union distribution[^1] を用いて `U` の各要素 `K` 取り出し、その `K` と `U` が等しければ、`U` は 1 要素の union ということになるので `false` を返し、そうでない場合は `true` を返す、という仕組みです。なお、最後の `never` に評価されることはありません(union distribution のイディオム)。 :::details ソースコード -https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/src/is-union.ts +https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/src/is-union.d.mts ::: :::details 使用例(ユニットテスト) -https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/test/is-union.ts +https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/test/is-union.mts ::: ## `ToNumber` @@ -189,7 +196,7 @@ expectType, 1000>('='); expectType, 8192>('='); ``` -数値の文字列型を数値型にします。 +数値の文字列型を数値型に変換します。 --- 実装 --- @@ -200,17 +207,17 @@ type ToNumber ``` :::message -注意: TypeScript 4.8 で実装された機能 に依存しているため、それ以前のバージョンでは tuple 型を経由して "length" プロパティを取り出す大掛かりな実装が必要になります。 +注意: TypeScript 4.8 で実装された機能に依存しているため、それ以前のバージョンでは tuple 型を経由して "length" プロパティを取り出す大掛かりな実装が必要になります。 @[card](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-8.html#improved-inference-for-infer-types-in-template-string-types) ::: :::details ソースコード -https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/src/to-number.ts +https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/src/to-number.d.mts ::: :::details 使用例(ユニットテスト) -https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/test/to-number.ts +https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/test/to-number.mts ::: ## `IsFixedLengthList` @@ -233,11 +240,11 @@ type IsFixedLengthList = 可変長配列( `readonly number[]` など)の`"length"` の型が `number` 型であるのに対して、固定長の配列型(タプル型、 `[1, 2, 3]` など)の `"length"` の型が `number` 型ではなく定数の型(`3`など)になることを利用しています。 :::details ソースコード -https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/src/is-fixed-length-list.ts +https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/src/is-fixed-length-list.d.mts ::: :::details 使用例(ユニットテスト) -https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/test/is-fixed-length-list.ts +https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/test/is-fixed-length-list.mts ::: ## `IndexOfTuple` @@ -248,14 +255,14 @@ expectType, 0 | 1 | 2 | 3 | 4>('='); expectType, never>('='); ``` -タプル型のインデックスを返します。 +タプル型のインデックスの union を返します。 --- 実装 --- ```ts -type IndexOfTuple = _IndexOfTupleImpl; +type IndexOfTuple = IndexOfTupleImpl; -type _IndexOfTupleImpl = +type IndexOfTupleImpl = IsFixedLengthList extends true ? K extends keyof T ? K extends `${number}` @@ -269,11 +276,11 @@ type _IndexOfTupleImpl = `K extends '${number}'`は `K` が `ToNumber` の制約を満たしているというヒントを型システムに与えるために追加していますが、 `IndexOfTuple` からの入力では必ず真になるので実質何もしていない条件部です。 :::details ソースコード -https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/src/index-of-tuple.ts +https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/src/index-of-tuple.d.mts ::: :::details 使用例(ユニットテスト) -https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/test/index-of-tuple.ts +https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/test/index-of-tuple.mts ::: ## `MakeTuple` @@ -287,78 +294,68 @@ expectType, readonly [unknown, unknown, unknown]>('='); --- 実装 --- ```ts -type MakeTuple = _MakeTupleInternals.MakeTupleImpl< - Elm, - `${N}`, - [] ->; - -namespace _MakeTupleInternals { - type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; - - type Tail = T extends `${Digit}${infer U}` ? U : never; - - type First = T extends `${infer U}${Tail}` ? U : never; - - type DigitStr = `${Digit}`; - - type Tile< - T extends readonly unknown[], - N extends Digit | DigitStr | '10' | 10, - > = [ - readonly [], - readonly [...T], - readonly [...T, ...T], - readonly [...T, ...T, ...T], - readonly [...T, ...T, ...T, ...T], - readonly [...T, ...T, ...T, ...T, ...T], - readonly [...T, ...T, ...T, ...T, ...T, ...T], - readonly [...T, ...T, ...T, ...T, ...T, ...T, ...T], - readonly [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T], - readonly [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T], - readonly [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T], - ][N]; - - export type MakeTupleImpl< - T, - N extends string, - X extends readonly unknown[], - > = string extends N - ? never - : N extends '' - ? X - : First extends infer U - ? U extends DigitStr - ? MakeTupleImpl< - T, - Tail, - readonly [...Tile<[T], U>, ...Tile] - > - : never - : never; -} +type MakeTuple = MakeTupleImpl; + +type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; + +type Tail = T extends `${Digit}${infer U}` ? U : never; + +type First = T extends `${infer U}${Tail}` ? U : never; + +type DigitStr = `${Digit}`; + +type Tile< + T extends readonly unknown[], + N extends Digit | DigitStr | '10' | 10, +> = [ + readonly [], + readonly [...T], + readonly [...T, ...T], + readonly [...T, ...T, ...T], + readonly [...T, ...T, ...T, ...T], + readonly [...T, ...T, ...T, ...T, ...T], + readonly [...T, ...T, ...T, ...T, ...T, ...T], + readonly [...T, ...T, ...T, ...T, ...T, ...T, ...T], + readonly [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T], + readonly [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T], + readonly [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T], +][N]; + +type MakeTupleImpl< + T, + N extends string, + X extends readonly unknown[], +> = string extends N + ? never + : N extends '' + ? X + : First extends infer U + ? U extends DigitStr + ? MakeTupleImpl, readonly [...Tile<[T], U>, ...Tile]> + : never + : never; ``` かなり大がかりですが、巨大な tuple 型を作ろうとしても再帰制限にひっかからないようにするためこのように実装が工夫がされています。以下の記事で紹介されていたものをほぼそのまま利用しました(`ToNumber` の実装だけ TypeScript 4.8 の機能を使い効率化しています)。 参考: https://techracho.bpsinc.jp/yoshi/2020_09_04/97108 -以下の単純な再帰を行う実装でも小さな `N` に対しては同様に動きますが、 `N` が大きい場合に再帰回数の制限にひっかかってしまいます。 +以下の単純な再帰を行う実装でも小さな `N` に対しては同様に動きますが、 `N` が大きい場合にすぐ再帰回数の制限にひっかかってしまいます。 `MakeTupleNaive` の再帰回数は $O(N)$ なのに対し、 `MakeTuple` の再帰回数は $O(\log_{10} N)$ になります。 ```ts -type MakeTupleNaive = _MakeTupleNaiveImpl< +type MakeTupleNaive = MakeTupleNaiveImpl< N, Elm, readonly [] >; /** @internal */ -type _MakeTupleNaiveImpl = +type MakeTupleNaiveImpl = // T extends { length: Num } ? T - : _MakeTupleNaiveImpl; + : MakeTupleNaiveImpl; ``` ```ts @@ -367,11 +364,11 @@ expectType, MakeTuple<0, 1000>>('='); ``` :::details ソースコード -https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/src/make-tuple.ts +https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/src/make-tuple.d.mts ::: :::details 使用例(ユニットテスト) -https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/test/make-tuple.ts +https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/test/make-tuple.mts ::: ## `Index` @@ -381,7 +378,7 @@ expectType, 0 | 1 | 2>('='); expectType, 0 | 1 | 2 | 3 | 4>('='); ``` -与えられた整数未満の非負整数すべてからなる union 型を返します。 +整数引数 `N` 未満の非負整数すべてからなる union 型 `0 | 1 | ... | N - 1` を返します。 --- 実装 --- @@ -389,14 +386,14 @@ expectType, 0 | 1 | 2 | 3 | 4>('='); type Index = IndexOfTuple>; ``` -`MakeTuple` を利用して tuple を作った後 `IndexOfTuple` でその index を取り出す、という実装をしています。 +`MakeTuple` を利用して長さ `N` の tuple を作った後、 `IndexOfTuple` でその tuple の index を取り出す、という実装をしています。 :::details ソースコード -https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/src/index-type.ts +https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/src/index-type.d.mts ::: :::details 使用例(ユニットテスト) -https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/test/index-type.ts +https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/test/index-type.mts ::: ## `NegativeIndex` @@ -406,61 +403,56 @@ expectType, never>('='); expectType, -1 | -2 | -3 | -4 | -5>('='); ``` -与えられた整数以上の負整数すべて(`0` は除く)からなる union 型を返します。 +整数 `N` を受け取り、 `-N` 以上の負整数すべて(`0` は除く)からなる union 型 `-N | -N + 1 | ... | -1` を返します。 --- 実装 --- ```ts -type NegativeIndex = _NegativeIndexImpl.MapIdx< +type NegativeIndex = NegativeIndexImpl.MapIdx< RelaxedExclude, 0> >; -namespace _NegativeIndexImpl { - type ToNumberFromNegative = - S extends `${infer N extends number}` ? N : never; +type ToNumberFromNegative = + S extends `${infer N extends number}` ? N : never; - export type MapIdx = I extends I - ? ToNumberFromNegative<`-${I}`> - : never; -} +type MapIdx = I extends I + ? ToNumberFromNegative<`-${I}`> + : never; ``` `Index` と同様 tuple 型の index を取り出す実装を使っていますが、負数にするためにその index `I` を `-${I}` で文字列化して数値として取り出すという実装をしています。 :::details ソースコード -https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/src/index-type.ts +https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/src/index-type.d.mts ::: :::details 使用例(ユニットテスト) -https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/test/index-type.ts +https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/test/index-type.mts ::: ## Enum types -`Index` 型を実装したので以下の型も定義しておきます。 +`Index` 型を実装したので以下のような型が定義できます。 ```ts /** `0 | 1 | ... | 255` */ type Uint8 = Index<256>; -/** `0 | 1 | ... | 511` */ -type Uint9 = Index<512>; - -/** `0 | 1 | ... | 1023` */ -type Uint10 = Index<1024>; - /** `-128 | -127 | ... | -1 | 0 | 1 | ... | 126 | 127` */ type Int8 = Readonly | NegativeIndex<129>>; -/** `-256 | -255 | ... | -1 | 0 | 1 | ... | 254 | 255` */ -type Int9 = Readonly | NegativeIndex<257>>; +/** `1 | 2 | ... | 12` */ +type MonthEnum = Exclude, 0>; + +/** `0 | 1 | ... | 59` */ +type SecondsEnum = Sexagesimal; -/** `-512 | -511 | ... | -1 | 0 | 1 | ... | 510 | 511` */ -type Int10 = Readonly | NegativeIndex<513>>; +/** `0 | 1 | ... | 999` */ +type MillisecondsEnum = Index<1000>; ``` :::details ソースコード -https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/src/enum.ts +https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/src/enum.d.mts ::: ## `UintRange` @@ -473,6 +465,9 @@ expectType, 0 | 1 | 2 | 3 | 4>('='); expectType, 2 | 3 | 4>('='); ``` +非負整数 `S`, `E` を受け取り、 `S` 以上 `E` 未満の整数全体の union を返します。 +`Index` と `Exclude` を組み合わせるだけで実装できます。 + --- 実装 --- ```ts @@ -482,14 +477,12 @@ type UintRange = Exclude< >; ``` -`Index` と `Exclude` を組み合わせるだけで実装できます。 - :::details ソースコード -https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/src/uint-range.ts +https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/src/uint-range.d.mts ::: :::details 使用例(ユニットテスト) -https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/test/uint-range.ts +https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/test/uint-range.mts ::: ## `Max`, `Min` @@ -509,15 +502,15 @@ expectType, 0>('='); 実装は[この記事](https://zenn.dev/noshiro_piko/articles/typescript-type-level-min)で解説しています。 :::details ソースコード -https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/src/max.ts +https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/src/max.d.mts -https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/src/min.ts +https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/src/min.d.mts ::: :::details 使用例(ユニットテスト) -https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/test/max.ts +https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/test/max.mts -https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/test/min.ts +https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/test/min.mts ::: ## `Seq` @@ -528,14 +521,14 @@ expectType, readonly []>('='); expectType, readonly [0, 1, 2, 3, 4]>('='); ``` -与えられた数値までの連番配列の型を返します。 +整数 `N` を受け取り、 0 から `N - 1` までの連番配列の型 `readonly [0, 1, ..., N - 1]` を返します。 --- 実装 --- ```ts -type Seq = _SeqImpl>; +type Seq = SeqImpl>; -type _SeqImpl = { +type SeqImpl = { readonly [i in keyof T]: i extends `${number}` ? ToNumber : never; }; ``` @@ -543,11 +536,11 @@ type _SeqImpl = { `MakeTuple` で長さ `N` の配列を作った後、その中身を Mapped Type で差し替えています。 :::details ソースコード -https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/src/seq.ts +https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/src/seq.d.mts ::: :::details 使用例(ユニットテスト) -https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/test/seq.ts +https://github.com/noshiro-pf/mono/blob/develop/packages/ts-type-utils/test/seq.mts ::: --- diff --git a/articles/typescript-void-type.md b/articles/typescript-void-type.md index 451a2efaf1..803bf03dcf 100644 --- a/articles/typescript-void-type.md +++ b/articles/typescript-void-type.md @@ -117,7 +117,7 @@ if (!!maybeUser) { このメソッドは、 `Promise` 型を戻り値とするメソッド `signinPopupCallback`, `signinSilentCallback` と `Promise` 型を戻り値とする `signinRedirectCallback` が中で呼び分けられる内部実装になっており、これをそのまま `Promise` という戻り値型にしているようでした。 ```ts -export class UserManager { +class UserManager { // ... public async signinCallback( diff --git a/docs/--- b/docs/--- new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/------ b/docs/------ new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/------- b/docs/------- new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/-------- b/docs/-------- new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/--------- b/docs/--------- new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/---------- b/docs/---------- new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/----------- b/docs/----------- new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/------------ b/docs/------------ new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/------------- b/docs/------------- new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/apps/algo-app/src/components/balloon/decided-answer-balloon.tsx b/packages/apps/algo-app/src/components/balloon/decided-answer-balloon.tsx index 02a3c13b00..ea1041d53c 100644 --- a/packages/apps/algo-app/src/components/balloon/decided-answer-balloon.tsx +++ b/packages/apps/algo-app/src/components/balloon/decided-answer-balloon.tsx @@ -77,38 +77,38 @@ export const DecidedAnswerBalloon = memoNamed( - + diff --git a/packages/apps/algo-app/src/components/bp/spinner.tsx b/packages/apps/algo-app/src/components/bp/spinner.tsx index 7ea2acf034..d04e7a3bf2 100644 --- a/packages/apps/algo-app/src/components/bp/spinner.tsx +++ b/packages/apps/algo-app/src/components/bp/spinner.tsx @@ -59,7 +59,7 @@ export const Spinner = memoNamed('Spinner', ({ size: _size, value }) => { // - SPINNER_ANIMATION isolates svg from parent display and is always centered inside root element. return ( // eslint-disable-next-line jsx-a11y/prefer-tag-over-role - + ( onMouseLeave={onMouseLeave} > ( visibilityFromMe === 'faceDownButVisibleToMe' ? ( <> @@ -155,10 +155,10 @@ export const CardComponent = memoNamed( ( <> - + - + )); diff --git a/packages/apps/algo-app/src/components/card/cards-sub/card-1.tsx b/packages/apps/algo-app/src/components/card/cards-sub/card-1.tsx index e63da61d3b..618663e606 100644 --- a/packages/apps/algo-app/src/components/card/cards-sub/card-1.tsx +++ b/packages/apps/algo-app/src/components/card/cards-sub/card-1.tsx @@ -2,7 +2,7 @@ import { type CardProps } from './card-props'; export const Card1 = memoNamed('Card1', ({ textColor }: CardProps) => ( )); diff --git a/packages/apps/algo-app/src/components/card/cards-sub/card-10.tsx b/packages/apps/algo-app/src/components/card/cards-sub/card-10.tsx index ac275cf222..97ed52ca12 100644 --- a/packages/apps/algo-app/src/components/card/cards-sub/card-10.tsx +++ b/packages/apps/algo-app/src/components/card/cards-sub/card-10.tsx @@ -3,25 +3,25 @@ import { type CardProps } from './card-props'; export const Card10 = memoNamed('Card10', ({ color, textColor }: CardProps) => ( <> - + - + diff --git a/packages/apps/algo-app/src/components/card/cards-sub/card-11.tsx b/packages/apps/algo-app/src/components/card/cards-sub/card-11.tsx index d8c9bc9bb6..419bdcc17c 100644 --- a/packages/apps/algo-app/src/components/card/cards-sub/card-11.tsx +++ b/packages/apps/algo-app/src/components/card/cards-sub/card-11.tsx @@ -3,11 +3,11 @@ import { type CardProps } from './card-props'; export const Card11 = memoNamed('Card11', ({ textColor }: CardProps) => ( <> diff --git a/packages/apps/algo-app/src/components/card/cards-sub/card-2.tsx b/packages/apps/algo-app/src/components/card/cards-sub/card-2.tsx index e0be54c9ad..744ffbd430 100644 --- a/packages/apps/algo-app/src/components/card/cards-sub/card-2.tsx +++ b/packages/apps/algo-app/src/components/card/cards-sub/card-2.tsx @@ -3,23 +3,23 @@ import { type CardProps } from './card-props'; export const Card2 = memoNamed('Card2', ({ textColor }: CardProps) => ( <> diff --git a/packages/apps/algo-app/src/components/card/cards-sub/card-3.tsx b/packages/apps/algo-app/src/components/card/cards-sub/card-3.tsx index c2a2170f4a..af93c0e48a 100644 --- a/packages/apps/algo-app/src/components/card/cards-sub/card-3.tsx +++ b/packages/apps/algo-app/src/components/card/cards-sub/card-3.tsx @@ -3,20 +3,20 @@ import { type CardProps } from './card-props'; export const Card3 = memoNamed('Card3', ({ color, textColor }: CardProps) => ( <> - + diff --git a/packages/apps/algo-app/src/components/card/cards-sub/card-4.tsx b/packages/apps/algo-app/src/components/card/cards-sub/card-4.tsx index 856d9c3f12..b5c6cd8716 100644 --- a/packages/apps/algo-app/src/components/card/cards-sub/card-4.tsx +++ b/packages/apps/algo-app/src/components/card/cards-sub/card-4.tsx @@ -2,7 +2,7 @@ import { type CardProps } from './card-props'; export const Card4 = memoNamed('Card4', ({ textColor }: CardProps) => ( )); diff --git a/packages/apps/algo-app/src/components/card/cards-sub/card-5.tsx b/packages/apps/algo-app/src/components/card/cards-sub/card-5.tsx index c5a462e44a..0955447f45 100644 --- a/packages/apps/algo-app/src/components/card/cards-sub/card-5.tsx +++ b/packages/apps/algo-app/src/components/card/cards-sub/card-5.tsx @@ -2,15 +2,15 @@ import { type CardProps } from './card-props'; export const Card5 = memoNamed('Card5', ({ color, textColor }: CardProps) => ( <> - + - - + + diff --git a/packages/apps/algo-app/src/components/card/cards-sub/card-6.tsx b/packages/apps/algo-app/src/components/card/cards-sub/card-6.tsx index 0bef612fe1..28a8779550 100644 --- a/packages/apps/algo-app/src/components/card/cards-sub/card-6.tsx +++ b/packages/apps/algo-app/src/components/card/cards-sub/card-6.tsx @@ -3,10 +3,10 @@ import { type CardProps } from './card-props'; export const Card6 = memoNamed('Card6', ({ color, textColor }: CardProps) => ( <> - - + + )); diff --git a/packages/apps/algo-app/src/components/card/cards-sub/card-7.tsx b/packages/apps/algo-app/src/components/card/cards-sub/card-7.tsx index db9e511447..26bb0d19b2 100644 --- a/packages/apps/algo-app/src/components/card/cards-sub/card-7.tsx +++ b/packages/apps/algo-app/src/components/card/cards-sub/card-7.tsx @@ -2,7 +2,7 @@ import { type CardProps } from './card-props'; export const Card7 = memoNamed('Card7', ({ textColor }: CardProps) => ( )); diff --git a/packages/apps/algo-app/src/components/card/cards-sub/card-8.tsx b/packages/apps/algo-app/src/components/card/cards-sub/card-8.tsx index 4b609f245b..9b046d4da2 100644 --- a/packages/apps/algo-app/src/components/card/cards-sub/card-8.tsx +++ b/packages/apps/algo-app/src/components/card/cards-sub/card-8.tsx @@ -2,12 +2,12 @@ import { type CardProps } from './card-props'; export const Card8 = memoNamed('Card8', ({ color, textColor }: CardProps) => ( <> - + - - + + )); diff --git a/packages/apps/algo-app/src/components/card/cards-sub/card-9.tsx b/packages/apps/algo-app/src/components/card/cards-sub/card-9.tsx index 96413c1dc6..5c81e4bd5d 100644 --- a/packages/apps/algo-app/src/components/card/cards-sub/card-9.tsx +++ b/packages/apps/algo-app/src/components/card/cards-sub/card-9.tsx @@ -2,10 +2,10 @@ import { type CardProps } from './card-props'; export const Card9 = memoNamed('Card9', ({ color, textColor }: CardProps) => ( <> - - + + diff --git a/packages/apps/algo-app/src/components/create-room-page.tsx b/packages/apps/algo-app/src/components/create-room-page.tsx index 5d94f42a26..26b6704315 100644 --- a/packages/apps/algo-app/src/components/create-room-page.tsx +++ b/packages/apps/algo-app/src/components/create-room-page.tsx @@ -40,7 +40,7 @@ export const CreateRoomPage = memoNamed('CreateRoomPage', () => { @@ -50,7 +50,7 @@ export const CreateRoomPage = memoNamed('CreateRoomPage', () => { @@ -58,7 +58,7 @@ export const CreateRoomPage = memoNamed('CreateRoomPage', () => { diff --git a/packages/apps/algo-app/src/components/game-main.tsx b/packages/apps/algo-app/src/components/game-main.tsx index b9f9087647..dd8c8fb0b2 100644 --- a/packages/apps/algo-app/src/components/game-main.tsx +++ b/packages/apps/algo-app/src/components/game-main.tsx @@ -68,7 +68,7 @@ export const GameMain = memoNamed('GameMain', ({ windowSize }) => { {dictionary.gameMain.endTurnButton} diff --git a/packages/apps/algo-app/src/components/join-room-page.tsx b/packages/apps/algo-app/src/components/join-room-page.tsx index 90d4951bf7..4b25855881 100644 --- a/packages/apps/algo-app/src/components/join-room-page.tsx +++ b/packages/apps/algo-app/src/components/join-room-page.tsx @@ -56,7 +56,7 @@ export const JoinRoomPage = memoNamed('JoinRoomPage', ({ roomId }) => { @@ -70,7 +70,7 @@ export const JoinRoomPage = memoNamed('JoinRoomPage', ({ roomId }) => { @@ -78,7 +78,7 @@ export const JoinRoomPage = memoNamed('JoinRoomPage', ({ roomId }) => { {loading ? : {dc.button}} diff --git a/packages/apps/algo-app/src/components/organisms/turn-player-highlighter.tsx b/packages/apps/algo-app/src/components/organisms/turn-player-highlighter.tsx index fee821c701..4cbe761dc2 100644 --- a/packages/apps/algo-app/src/components/organisms/turn-player-highlighter.tsx +++ b/packages/apps/algo-app/src/components/organisms/turn-player-highlighter.tsx @@ -7,16 +7,19 @@ type Props = Readonly<{ export const TurnPlayerHighlighter = memoNamed( 'TurnPlayerHighlighter', - ({ position }) => ( - { + const style = useMemo( + () => ({ top: `${position.top - playerNameRectPadding}px`, left: `${position.left - playerNameRectPadding}px`, width: `${position.width + 2 * playerNameRectPadding}px`, height: `${position.height + 2 * playerNameRectPadding}px`, - }} - /> - ), + }), + [position.height, position.left, position.top, position.width], + ); + + return ; + }, ); const Rectangle = styled('div')` diff --git a/packages/apps/blueprintjs-playground-styled/src/components/input-group-view.tsx b/packages/apps/blueprintjs-playground-styled/src/components/input-group-view.tsx index d0808ea4b3..3c261b243a 100644 --- a/packages/apps/blueprintjs-playground-styled/src/components/input-group-view.tsx +++ b/packages/apps/blueprintjs-playground-styled/src/components/input-group-view.tsx @@ -29,7 +29,7 @@ export const InputGroupView = memoNamed( `} disabled={disabled} placeholder={placeholder} - type='text' + type={"text"} value={value} onChange={onChange} /> diff --git a/packages/apps/blueprintjs-playground-styled/src/components/numeric-input-view.tsx b/packages/apps/blueprintjs-playground-styled/src/components/numeric-input-view.tsx index e91ddae614..5486a80f20 100644 --- a/packages/apps/blueprintjs-playground-styled/src/components/numeric-input-view.tsx +++ b/packages/apps/blueprintjs-playground-styled/src/components/numeric-input-view.tsx @@ -61,40 +61,40 @@ export const NumericInputView = memoNamed( `} >
- -
- } + + + + + ), + [onSortAscClick, onSortDescClick], + ); + return ( - - - - } + content={popoverContent} isOpen={isOpen} placement={'bottom'} onClose={handleClose} diff --git a/packages/apps/event-schedule-app/src/components/organisms/confirm-email-dialog.tsx b/packages/apps/event-schedule-app/src/components/organisms/confirm-email-dialog.tsx index 18314382ca..65cc5c9830 100644 --- a/packages/apps/event-schedule-app/src/components/organisms/confirm-email-dialog.tsx +++ b/packages/apps/event-schedule-app/src/components/organisms/confirm-email-dialog.tsx @@ -45,7 +45,7 @@ export const ConfirmEmailDialog = memoNamed( ( // eslint-disable-next-line jsx-a11y/no-autofocus autoFocus={true} intent={state.emailFormIntent} - rightElement={ - + ), + [enterButtonDisabled, isWaitingResponse], + ); + + return ( + - {dc.button.deleteAccount} - - } + submitButton={submitButton} title={dc.deleteAccount.title} /> ); }, ); + +const verifyEmailInputLabel = ; diff --git a/packages/apps/event-schedule-app/src/components/organisms/update-user-info-dialog/delete-account-dialog.tsx b/packages/apps/event-schedule-app/src/components/organisms/update-user-info-dialog/delete-account-dialog.tsx index ef62484f68..fc348270d7 100644 --- a/packages/apps/event-schedule-app/src/components/organisms/update-user-info-dialog/delete-account-dialog.tsx +++ b/packages/apps/event-schedule-app/src/components/organisms/update-user-info-dialog/delete-account-dialog.tsx @@ -25,71 +25,99 @@ export const DeleteAccountDialog = memoNamed( passwordIsOpen, } = useObservableValue(DeleteAccountPageStore.state); - return ( - ( + + ), + [formState.isWaitingResponse, passwordIsOpen], + ); + + const body = useMemo( + () => ( +
+ - {dc.deleteAccount.verifyEmail}} - > - - - + + + {dc.reauthenticate.password}} - > - - } - type={passwordIsOpen ? 'text' : 'password'} - value={formState.password.inputValue} - onValueChange={DeleteAccountPageStore.inputPasswordHandler} - /> - -
- } + rightElement={passwordLockButton} + type={passwordIsOpen ? 'text' : 'password'} + value={formState.password.inputValue} + onValueChange={DeleteAccountPageStore.inputPasswordHandler} + /> + + + ), + [ + emailFormIntent, + formState.email.error, + formState.email.inputValue, + formState.isWaitingResponse, + formState.password.error, + formState.password.inputValue, + passwordFormIntent, + passwordIsOpen, + passwordLockButton, + ], + ); + + const submitButton = useMemo( + () => ( + + ), + [enterButtonDisabled, formState.isWaitingResponse], + ); + + return ( + - {dc.button.deleteAccount} - - } + submitButton={submitButton} title={dc.deleteAccount.title} /> ); }, ); + +const passwordInputLabel = ; +const verifyEmailInputLabel = ; diff --git a/packages/apps/event-schedule-app/src/components/organisms/update-user-info-dialog/update-display-name-dialog.tsx b/packages/apps/event-schedule-app/src/components/organisms/update-user-info-dialog/update-display-name-dialog.tsx index 7553178f1a..3864df0740 100644 --- a/packages/apps/event-schedule-app/src/components/organisms/update-user-info-dialog/update-display-name-dialog.tsx +++ b/packages/apps/event-schedule-app/src/components/organisms/update-user-info-dialog/update-display-name-dialog.tsx @@ -19,49 +19,66 @@ export const UpdateDisplayNameDialog = memoNamed( const { formState, displayNameFormIntent, enterButtonDisabled } = useObservableValue(UpdateDisplayNamePageStore.state); - return ( - ( +
+ - {dc.updateDisplayName.newDisplayName}} - > - - -
- } + type={'text'} + value={formState.displayName.inputValue} + onValueChange={UpdateDisplayNamePageStore.inputDisplayNameHandler} + /> + + + ), + [ + displayNameFormIntent, + formState.displayName.error, + formState.displayName.inputValue, + formState.isWaitingResponse, + ], + ); + + const submitButton = useMemo( + () => ( + + ), + [enterButtonDisabled, formState.isWaitingResponse], + ); + + return ( + - {dc.button.update} - - } + submitButton={submitButton} title={dc.updateDisplayName.title} /> ); }, ); + +const newDisplayNameInputLabel = ( + +); diff --git a/packages/apps/event-schedule-app/src/components/organisms/update-user-info-dialog/update-email-dialog.tsx b/packages/apps/event-schedule-app/src/components/organisms/update-user-info-dialog/update-email-dialog.tsx index acf7034744..263ca4e044 100644 --- a/packages/apps/event-schedule-app/src/components/organisms/update-user-info-dialog/update-email-dialog.tsx +++ b/packages/apps/event-schedule-app/src/components/organisms/update-user-info-dialog/update-email-dialog.tsx @@ -26,75 +26,102 @@ export const UpdateEmailDialog = memoNamed( passwordIsOpen, } = useObservableValue(UpdateEmailPageStore.state); - return ( - ( + + ), + [formState.isWaitingResponse, passwordIsOpen], + ); + + const body = useMemo( + () => ( +
+ +
{currentEmail ?? ''}
+
+ - {dc.updateEmail.currentEmail}} - > -
{currentEmail ?? ''}
-
- {dc.updateEmail.newEmail}} - > - - - + + + {dc.reauthenticate.password}} - > - - } - type={passwordIsOpen ? 'text' : 'password'} - value={formState.password.inputValue} - onValueChange={UpdateEmailPageStore.inputPasswordHandler} - /> - -
- } + rightElement={passwordLockButton} + type={passwordIsOpen ? 'text' : 'password'} + value={formState.password.inputValue} + onValueChange={UpdateEmailPageStore.inputPasswordHandler} + /> + + + ), + [ + currentEmail, + emailFormIntent, + formState.email.error, + formState.email.inputValue, + formState.isWaitingResponse, + formState.password.error, + formState.password.inputValue, + passwordFormIntent, + passwordIsOpen, + passwordLockButton, + ], + ); + + const submitButton = useMemo( + () => ( + + ), + [enterButtonDisabled, formState.isWaitingResponse], + ); + + return ( + - {dc.button.update} - - } + submitButton={submitButton} title={dc.updateEmail.title} /> ); }, ); + +const passwordInputLabel = ; +const currentEmailInputLabel = ; +const newEmailInputLabel = ; diff --git a/packages/apps/event-schedule-app/src/components/organisms/update-user-info-dialog/update-password-dialog.tsx b/packages/apps/event-schedule-app/src/components/organisms/update-user-info-dialog/update-password-dialog.tsx index 5a1cca9b02..149f1d7447 100644 --- a/packages/apps/event-schedule-app/src/components/organisms/update-user-info-dialog/update-password-dialog.tsx +++ b/packages/apps/event-schedule-app/src/components/organisms/update-user-info-dialog/update-password-dialog.tsx @@ -30,109 +30,151 @@ export const UpdatePasswordDialog = memoNamed( newPasswordIsOpen, } = useObservableValue(UpdatePasswordPageStore.state); - return ( - ( + + ), + [formState.isWaitingResponse, newPasswordIsOpen], + ); + + const oldPasswordInputButton = useMemo( + () => ( + + ), + [formState.isWaitingResponse, oldPasswordIsOpen], + ); + + const body = useMemo( + () => ( +
+ - {dc.updatePassword.currentEmail}} - style={hideStyle} - > - - + type={'email'} + value={currentEmail ?? ''} + onValueChange={noop} + /> + - + {dc.updatePassword.oldPassword}} - > - - } - type={oldPasswordIsOpen ? 'text' : 'password'} - value={formState.oldPassword.inputValue} - onValueChange={UpdatePasswordPageStore.inputOldPasswordHandler} - /> - + rightElement={oldPasswordInputButton} + type={oldPasswordIsOpen ? 'text' : 'password'} + value={formState.oldPassword.inputValue} + onValueChange={UpdatePasswordPageStore.inputOldPasswordHandler} + /> + - + {dc.updatePassword.newPassword}} - > - - + type={'password'} + value={formState.newPassword.password.inputValue} + onValueChange={UpdatePasswordPageStore.inputNewPasswordHandler} + /> + - + {dc.updatePassword.verifyNewPassword}} - > - - } - type={newPasswordIsOpen ? 'text' : 'password'} - value={formState.newPassword.passwordConfirmation.inputValue} - onValueChange={ - UpdatePasswordPageStore.inputNewPasswordConfirmationHandler - } - /> - -
- } + rightElement={newPasswordLockButton} + type={newPasswordIsOpen ? 'text' : 'password'} + value={formState.newPassword.passwordConfirmation.inputValue} + onValueChange={ + UpdatePasswordPageStore.inputNewPasswordConfirmationHandler + } + /> + + + ), + [ + currentEmail, + formState.isWaitingResponse, + formState.newPassword.password.error, + formState.newPassword.password.inputValue, + formState.newPassword.passwordConfirmation.error, + formState.newPassword.passwordConfirmation.inputValue, + formState.oldPassword.error, + formState.oldPassword.inputValue, + newPasswordFormIntent, + newPasswordIsOpen, + newPasswordLockButton, + oldPasswordFormIntent, + oldPasswordInputButton, + oldPasswordIsOpen, + ], + ); + + const submitButton = useMemo( + () => ( + + ), + [enterButtonDisabled, formState.isWaitingResponse], + ); + + return ( + - {dc.button.update} - - } + submitButton={submitButton} title={dc.updatePassword.title} /> ); }, ); + +const oldPasswordInputLabel = ; +const currentEmailInputLabel = ; +const newPasswordInputLabel = ; +const verifyNewPasswordInputLabel = ( + +); diff --git a/packages/apps/event-schedule-app/src/components/pages/answer-page/answer-page.tsx b/packages/apps/event-schedule-app/src/components/pages/answer-page/answer-page.tsx index a1d58efeae..d2426ce5bd 100644 --- a/packages/apps/event-schedule-app/src/components/pages/answer-page/answer-page.tsx +++ b/packages/apps/event-schedule-app/src/components/pages/answer-page/answer-page.tsx @@ -240,13 +240,7 @@ export const AnswerPage = memoNamed('AnswerPage', () => { addOnBlur={false} addOnPaste={false} leftIcon={'filter-list'} - rightElement={ - +
+ + +
{ - const onClick = useRouterLinkClick({ - replace: false, - pushFn: Router.push, - redirectFn: Router.redirect, - }); +export const NotFoundPage = memoNamed('NotFoundPage', () => ( + +)); - return ( - - - {dict.topPage} - - } - icon={'search'} - title={dict.pageNotFound} - /> - ); +const onClick = createRouterLinkClickHandler({ + replace: false, + pushFn: Router.push, + redirectFn: Router.redirect, }); + +const action = ( + + + {dict.topPage} + +); diff --git a/packages/apps/event-schedule-app/src/components/pages/register-page.tsx b/packages/apps/event-schedule-app/src/components/pages/register-page.tsx index 8308dd713b..68e8627ed0 100644 --- a/packages/apps/event-schedule-app/src/components/pages/register-page.tsx +++ b/packages/apps/event-schedule-app/src/components/pages/register-page.tsx @@ -23,6 +23,17 @@ export const RegisterPage = memoNamed('RegisterPage', () => { passwordIsOpen, } = useObservableValue(RegisterPageStore.state); + const passwordLockButton = useMemo( + () => ( + + ), + [formState.isWaitingResponse, passwordIsOpen], + ); + return ( @@ -34,7 +45,7 @@ export const RegisterPage = memoNamed('RegisterPage', () => { {dc.username}} + label={usernameInputLabel} > { {dc.email}} + label={emailInputLabel} > { {dc.password}} + label={newPasswordInputLabel} > { {dc.verifyPassword}} + label={verifyPasswordInputLabel} > - } + rightElement={passwordLockButton} type={passwordIsOpen ? 'text' : 'password'} value={formState.password.passwordConfirmation.inputValue} onValueChange={ @@ -156,3 +161,8 @@ export const RegisterPage = memoNamed('RegisterPage', () => { const FormRectWrapper = styled(SignInStyled.FormRectWrapperBase)` height: 510px; `; + +const usernameInputLabel = ; +const emailInputLabel = ; +const newPasswordInputLabel = ; +const verifyPasswordInputLabel = ; diff --git a/packages/apps/event-schedule-app/src/components/pages/reset-password-page.tsx b/packages/apps/event-schedule-app/src/components/pages/reset-password-page.tsx index fefd9657a7..063e168100 100644 --- a/packages/apps/event-schedule-app/src/components/pages/reset-password-page.tsx +++ b/packages/apps/event-schedule-app/src/components/pages/reset-password-page.tsx @@ -39,7 +39,7 @@ export const ResetPasswordPage = memoNamed( {dc.email}} + label={emailInputLabel} > ( }, ); +const emailInputLabel = ; + const FormRectWrapper = styled(SignInStyled.FormRectWrapperBase)` height: 320px; position: relative; diff --git a/packages/apps/event-schedule-app/src/components/pages/sign-in-page.tsx b/packages/apps/event-schedule-app/src/components/pages/sign-in-page.tsx index 424661cbb0..da4bd5ace7 100644 --- a/packages/apps/event-schedule-app/src/components/pages/sign-in-page.tsx +++ b/packages/apps/event-schedule-app/src/components/pages/sign-in-page.tsx @@ -28,6 +28,17 @@ export const SignInPage = memoNamed('SignInPage', () => { { setTrue: passwordIsOpenResetForm, setFalse: hidePasswordResetForm }, ] = useBoolState(false); + const lockPasswordButton = useMemo( + () => ( + + ), + [formState.isWaitingResponse, passwordIsOpen], + ); + return ( @@ -42,7 +53,7 @@ export const SignInPage = memoNamed('SignInPage', () => { {dc.email}} + label={emailInputLabel} > { {dc.password}} + label={passwordInputLabel} > - } + rightElement={lockPasswordButton} type={passwordIsOpen ? 'text' : 'password'} value={formState.password.inputValue} onValueChange={SignInPageStore.inputPasswordHandler} @@ -140,6 +145,9 @@ export const SignInPage = memoNamed('SignInPage', () => { ); }); +const emailInputLabel = ; +const passwordInputLabel = ; + const FormRectWrapper = styled(SignInStyled.FormRectWrapperBase)` height: 420px; `; diff --git a/packages/apps/event-schedule-app/src/components/pages/ui-parts-test.tsx b/packages/apps/event-schedule-app/src/components/pages/ui-parts-test.tsx index cb97b4bb5e..f8672c7d93 100644 --- a/packages/apps/event-schedule-app/src/components/pages/ui-parts-test.tsx +++ b/packages/apps/event-schedule-app/src/components/pages/ui-parts-test.tsx @@ -75,27 +75,31 @@ export const UiPartsTest = memoNamed('UiPartsTest', () => {