開発上のメモ(環境構築でハマったこと・実装アンチパターン、調査等)
後で何のためにやった設定か分からなくなることがある(特に「あえてこうしていない」系のもの)ので作業履歴として諸々残していくことにした。
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 )
workspace のルートの型チェック用 tsconfig.json に cypress ディレクトリを含めたら jest の型定義と cypress の型定義が conflict することが分かったため、 cypress 用の type-check 用 tsconfig と eslint.config.js は分けるようにした。
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 をサポートしたらディレクトリ構造を元に戻して復活させるかもしれない(が、ディレクトリを分けた方がすっきりするのであまり気が進まない)。
状態管理アンチパターンメモ
- reducer を動的に変更しない
- reducer 関数を動的に変更するのではなく、依存している変数を state に含めて reducer は静的にすべき
先日 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
の中で計算するようにして B
を React.memo
で memoize すべきであるから、これも return 文を分ける強い理由にはならない。
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 }; としたい
TypeScript の exactOptionalPropertyTypes
オプションは、有効化すると optional property をバケツリレーすることができなくなってしまう( Type 'undefined' is not assignable to type ...
とエラーが出てしまう)ので有効化しづらい。
Extract 型の使い方
table の column のうち一部だけ sortable であるような状況で、 type ColumnId = "name" | "description" | "updatedAt"
型の一部を SortableColumnId
型として定義したいとき、 単に type SortableColumnId = "name" | "updatedAt"
とするよりも type SortableColumnId = Extract<ColumnId, "name" | "updatedAt">
とした方が SortableColumnId
型が ColumnId
型の部分型になっていることを保証できて、将来的に ColumnId
が変更された場合などに追従しやすくなる。
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();
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
内にエラーが生じた。
vitest config で globals: false とし、 tsconfig で "types": ["vitest/globals"]
を有効化せず、明示的に expect
や test
などの関数を 'vitest'
から import してテストを書く方法があるが、この方法は In Source Testing と相性が悪かった。
なぜなら、
import 'vitest';
という import 文がビルド生成物に紛れ込んでしまうためである。
素直に "vitest/globals" を有効にして import を省略してテストを書く方が良さそう。