Open12

開発上のメモ(環境構築でハマったこと・実装アンチパターン、調査等)

Hideaki NoshiroHideaki Noshiro

後で何のためにやった設定か分からなくなることがある(特に「あえてこうしていない」系のもの)ので作業履歴として諸々残していくことにした。

Hideaki NoshiroHideaki Noshiro

TypeScript v5 にアップデートして tsconfig.json の extends に配列を用いるようにしたら cypress run 実行時に

DevTools listening on ws://127.0.0.1:36551/devtools/browser/db3972a0-fbb9-4e6a-9fe0-4692cf1abf05
[8956:0115/181526.210849:ERROR:object_proxy.cc(590)] Failed to call method: org.freedesktop.portal.Settings.Read: object_path= /org/freedesktop/portal/desktop: unknown error type:

node:internal/process/esm_loader:34
      internalBinding('errors').triggerUncaughtException(
                                ^
TypeError [Error]: value.replace is not a function
    at normalizeSlashes (/home/xxx/.cache/Cypress/13.6.2/Cypress/resources/app/node_modules/ts-node/dist/util.js:62:18)
    at Object.getExtendsConfigPath (/home/xxx/.cache/Cypress/13.6.2/Cypress/resources/app/node_modules/ts-node/dist/ts-internals.js:24:54)
    at readConfig (/home/xxx/.cache/Cypress/13.6.2/Cypress/resources/app/node_modules/ts-node/dist/configuration.js:127:64)
    at findAndReadConfig (/home/xxx/.cache/Cypress/13.6.2/Cypress/resources/app/node_modules/ts-node/dist/configuration.js:50:84)
    at create (/home/xxx/.cache/Cypress/13.6.2/Cypress/resources/app/node_modules/ts-node/dist/index.js:146:69)
    at register (/home/xxx/.cache/Cypress/13.6.2/Cypress/resources/app/node_modules/ts-node/dist/index.js:127:19)
    at Object.registerAndCreateEsmHooks (/home/xxx/.cache/Cypress/13.6.2/Cypress/resources/app/node_modules/ts-node/dist/esm.js:25:49)
    at file:///home/xxx/.cache/Cypress/13.6.2/Cypress/resources/app/node_modules/ts-node/esm/transpile-only.mjs:8:7
    at ModuleJob.run (node:internal/modules/esm/module_job:218:25)
    at async ModuleLoader.import (node:internal/modules/esm/loader:329:24)

というエラーが出るようになってしまった。

tsconfig.json のパスを指定するオプションが分からなかったので、 cypress 実行ディレクトリをネストさせて tsconfig.json を分離するようにして回避した。

(間違ってコメントした issue : https://github.com/cypress-io/cypress/issues/28061#issuecomment-1891652364

Hideaki NoshiroHideaki Noshiro

workspace のルートの型チェック用 tsconfig.json に cypress ディレクトリを含めたら jest の型定義と cypress の型定義が conflict することが分かったため、 cypress 用の type-check 用 tsconfig と eslint.config.js は分けるようにした。

Hideaki NoshiroHideaki Noshiro

cypress ディレクトリをネストさせたら cypress-fail-on-console-error が実行時エラーを吐くようになった。

Error: Webpack Compilation Error
Module not found: Error: Can't resolve 'process/browser' in '/home/xxx/repo/node_modules/sinon/pkg'

Cypress は特定のディレクトリ構造に強く依存している傾向があるっぽくて気持ち悪いが、今回は他の選択肢が無いので cypress-fail-on-console-error は削除して対応することにする。

今後 Cypress が tsconfig.json の multiple extends をサポートしたらディレクトリ構造を元に戻して復活させるかもしれない(が、ディレクトリを分けた方がすっきりするのであまり気が進まない)。

Hideaki NoshiroHideaki Noshiro

状態管理アンチパターンメモ

  • reducer を動的に変更しない
    • reducer 関数を動的に変更するのではなく、依存している変数を state に含めて reducer は静的にすべき
Hideaki NoshiroHideaki Noshiro

先日 PRレビューしながら気づいたこと(自分が前からやっている実装パターンの理由メモ)。

React のコンポーネント実装において、条件分岐で JSX.Element の return 文を複数に分けて書かない方が見やすい。 hooks は条件分岐で呼ばれたり呼ばれなかったりするとエラーになるため、 React component 関数の実装では early return パターンを用いることはできず、 hooks を使わないのはもっとまずいので、React コンポーネント実装において return 文は常に関数末尾にまとめられることになる。それならば、一つの return 文にまとめてしまって三項演算子 cond ? <A /> : <B /> を使って書いた方が宣言的になり、そのコンポーネントの view の全体像が分かりやすくなる。
cond が null check であり、 <B /> の表示前に non-null が保証された値についてさらに何か計算した結果を表示したい、というケースは考えられるが、それほど複雑になる時点でコンポーネントを分けて B の中で計算するようにして BReact.memo で memoize すべきであるから、これも return 文を分ける強い理由にはならない。

Hideaki NoshiroHideaki Noshiro

Object.prototype.fromEntries は、TypeScript の標準型定義で

interface ObjectConstructor {
    fromEntries<T = any>(entries: Iterable<readonly [PropertyKey, T]>): { [k: string]: T; };
}

と定義されているが、これを key の型を string に潰さないようにした

interface ObjectConstructor {
    fromEntries<K extends PropertyKey, V>(
      entries: Iterable<readonly [K, V]>
    ): Record<K, V>;
}

という型に上書きして使っていたところ、以下の例で嘘の型になってしまうという問題に遭遇した。

const entries: ['x' | 'y' | 'z', number][] = [
  ['x', 1],
  ['y', 3],
] as const;

const obj = Object.fromEntries(entries); // Record<'x' | 'y' | 'z', number>

obj.x satisfies number;
obj.y satisfies number;
obj.z satisfies number;

この例において、 entries という変数は、型注釈が ["x" | "y" | "z", number][] となっているが実際の値として z は含まれていない。そしてこの entries を先ほどの fromEntries に渡すと、ランタイムの値としては { x: 1, y: 3 } というオブジェクトになるが、型注釈が { x: number, y: number, z: number } となり、 z にもアクセスできる型になってしまう。

よって、 fromEntries の戻り値には、(少なくとも key が union 型の entries が入ってくるケースに関しては) Partial を付ける必要がある。

そこで、以下のように型を改善した。

interface ObjectConstructor {
    fromEntries<K extends PropertyKey, V>(
      entries: Iterable<readonly [K, V]>
    ): IsUnion<K> extends true
      ? Partial<Record<K, V>>
      : Record<K, V>;
}

type IsNever<T> = [T] extends [never] ? true : false;

type BoolNot<A extends boolean> =
  TypeEq<A, true> extends true
    ? false
    : TypeEq<A, false> extends true
      ? true
      : never;

type IsUnion<U> = _IsUnionImpl<U, U>;

/** @internal */
type _IsUnionImpl<U, K extends U> =
  IsNever<U> extends true
    ? false
    : K extends K
      ? BoolNot<TypeEq<U, K>>
      : never;

未実装だが、引数がタプルの場合については key-value の組み合わせまで結果に反映した型を返せるようにしても良さそう。

const entries = [
  ['x', 1],
  ['y', 3],
] as const satisfies [['x', 1], ['y', 3]];

const obj = Object.fromEntries(entries) // satisfies { x: 1, y: 3 }; としたい
Hideaki NoshiroHideaki Noshiro

TypeScript の exactOptionalPropertyTypes オプションは、有効化すると optional property をバケツリレーすることができなくなってしまう( Type 'undefined' is not assignable to type ... とエラーが出てしまう)ので有効化しづらい。

Hideaki NoshiroHideaki Noshiro

Extract 型の使い方

table の column のうち一部だけ sortable であるような状況で、 type ColumnId = "name" | "description" | "updatedAt" 型の一部を SortableColumnId 型として定義したいとき、 単に type SortableColumnId = "name" | "updatedAt" とするよりも type SortableColumnId = Extract<ColumnId, "name" | "updatedAt"> とした方が SortableColumnId 型が ColumnId 型の部分型になっていることを保証できて、将来的に ColumnId が変更された場合などに追従しやすくなる。

Hideaki NoshiroHideaki Noshiro

ts-pattern の調査

import { match } from 'ts-pattern';

type Cat = Readonly<{ type: 'Cat'; x: 'Meow' }>;
type Dog = Readonly<{ type: 'Dog'; y: 'Bow' }>;

type Animal = Cat | Dog;

const isCat = (v: Animal): v is Cat => v.type === 'Cat';
const isDog = (v: Animal): v is Dog => v.type === 'Dog';

export const say = (animal: Animal) =>
  match(animal)
    .when(isCat, (v) => v.x)
    .when(isDog, (v) => v.y)
    .exhaustive();

このようなコードが書ける。

ただし、 isCat, isDog のようなガード関数でないと上手くいかないっぽいので、三項演算子の羅列になっているコードを完全に置き換えられるわけではなさそう(TypeScript 5.5 でも同様)。

import { match } from 'ts-pattern';

type Animal = Readonly<{ type: 'Cat'; x: 'Meow' } | { type: 'Dog'; y: 'Bow' }>;

export const say = (animal: Animal) =>
  match(animal)
    .when(
      (v) => v.type === 'Cat',
      (v) => v.x,
    )
    .when(
      (v) => v.type === 'Dog',
      (v) => v.y,
    )
    // @ts-expect-error
    .exhaustive();
Hideaki NoshiroHideaki Noshiro

https://vitest.dev/guide/in-source.html

vitest は Rust のようにソースコード内にテストコードブロックを追加する方法を提供しているらしい。
if (import.meta.vitest !== undefined) { /* test code */ } というブロックを用いることで vitest 実行時のみこのコードが呼ばれるようになる。
production build ではこのコードは削除する必要があるが、 bundler をいくつか試した結論として、 rollup 以外はライブラリコードのトランスパイルには適さないようだった。

まず vite のライブラリモードは opinionated な設定のみを提供していてカスタマイズが難しく、ファイルごとのビルドをサポートしていなかったため 1 ファイルに bundle した出力しかできなさそうで、 app に import してビルドしてみたところ tree shaking が上手くいっていなかった。

ファイルごとの出力ができる選択肢に unbuild × mkdist と、 rollup × preserveModules による方法があるが、前者は import.meta.vitest ブロックを削除するコードが上手く動かない(おそらく mkdist ではなく rollup を bundler に用いた場合を想定したドキュメントになっている?)。

以下が TypeScript のライブラリコードをファイル単位でトランスパイルする設定の結論 config。

// rollup.config.ts

import * as replace from '@rollup/plugin-replace';
import * as typescript from '@rollup/plugin-typescript';
import { defineConfig } from 'rollup';

const config = defineConfig({
  input: 'src/index.ts',
  output: {
    dir: 'dist',
    preserveModules: true,
  },
  plugins: [
    replace.default({
      'import.meta.vitest': 'undefined',
    }),
    typescript.default({
      declaration: true,
      rootDir: 'src',
      declarationDir: 'dist',
    }),
  ],
});

// eslint-disable-next-line import/no-default-export
export default config;

なお tsconfig で

{
  "compilerOptions": {
    "allowSyntheticDefaultImports": false,
    "esModuleInterop": true
  }
}

を有効にしていないと rollup.config.ts 内にエラーが生じた。

Hideaki NoshiroHideaki Noshiro

vitest config で globals: false とし、 tsconfig で "types": ["vitest/globals"] を有効化せず、明示的に expecttest などの関数を 'vitest' から import してテストを書く方法があるが、この方法は In Source Testing と相性が悪かった。

なぜなら、

import 'vitest';

という import 文がビルド生成物に紛れ込んでしまうためである。

素直に "vitest/globals" を有効にして import を省略してテストを書く方が良さそう。