diff --git a/CLAUDE.md b/CLAUDE.md index 9625774..4899fcf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ `react-component-test-suite` — consistent, reusable test suites for React components in Jest or Vitest, naming each `describe` block after the component under test. Published to npm, bundled by tsdown. -- **Layout**: `src/component-suite.tsx` is the entrypoint, `src/utils.ts` its helpers; unit tests live in `src/__tests__/` with shared fixtures in `src/__tests__/__helpers__/`. +- **Layout**: `src/component-suite.tsx` is the entrypoint, `src/utils/index.ts` its helpers; unit tests live in `src/__tests__/` with shared fixtures in `src/__tests__/__helpers__/`. - **Peers**: `react`, `@types/react`, and the Testing Library packages are peers; `vitest` and `jest` are optional peers, so nothing may import either test runner at module scope. ## Code conventions @@ -22,4 +22,5 @@ Conventions live outside this file, synced from https://github.com/bvandrc/bvand ## Repo conventions - **Package manager**: pnpm. `npm install` writes a competing `package-lock.json` that CI ignores. +- **Typed `Object` walks**: `typedKeys`, `typedEntries`, and `typedFromEntries`, exported from `src/utils/index.ts`, are ours. They are the builtins with the cast a call site would otherwise write by hand, so reach for one instead of asserting the result -- and leave `Object.keys`/`entries`/`fromEntries` alone where the widened type is what's wanted. - **Convention files**: `conventions/` is synced from https://github.com/bvandrc/bvandrc-conventions by `.github/workflows/sync-conventions.yml` and overwritten on every sync. Edit a rule upstream, never in that directory. diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index 31ab4ad..0000000 --- a/src/utils.ts +++ /dev/null @@ -1,15 +0,0 @@ -export type AnyFunctionComponent = React.FunctionComponentElement - -/** The component's name, used to title the suite written for it. */ -export const getComponentName = (Component: AnyFunctionComponent): string => { - const name = - Component.type.name || - Component.type.displayName || - (Component as unknown as { displayName?: string }).displayName - if (!name) { - throw new Error( - 'Component has no name. May be a React.MemoExoticComponent-- if so, set displayName on the component.' - ) - } - return name -} diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..d58cc52 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,55 @@ +import type { Entries } from 'type-fest' + +/** + * `Object.keys`, typed as the keys of what was passed. + * + * Narrower than the runtime guarantees: a value can carry keys beyond the ones + * its type names, which is the cast being made on purpose. + * + * Restricted to plain records: an array's `keyof` names its string methods, not + * the numeric-as-string indices `Object.keys` actually returns, so an array + * argument is refused rather than mistyped. + */ +export const typedKeys = >(o: T) => + Object.keys(o) as (keyof T & string)[] + +/** + * `Object.entries`, typed as the entries of what was passed. + * + * Carries the key-to-value correlation that `Object.entries` drops, so + * destructuring an entry of a union-valued object narrows. Refuses an array + * argument for the same reason as `typedKeys`. + */ +export const typedEntries = >(o: T) => + Object.entries(o) as Entries + +/** + * `Object.fromEntries`, typed as the object those entries build. + * + * Only as precise as the entries it is handed, so a `.map` that wants each key + * paired with its own value type annotates the callback's return as a tuple. + */ +export const typedFromEntries = < + const EntryList extends readonly (readonly [PropertyKey, unknown])[], +>( + entries: EntryList +) => + Object.fromEntries(entries) as { + [Entry in EntryList[number] as Entry[0]]: Entry[1] + } + +export type AnyFunctionComponent = React.FunctionComponentElement + +/** The component's name, used to title the suite written for it. */ +export const getComponentName = (Component: AnyFunctionComponent): string => { + const name = + Component.type.name || + Component.type.displayName || + (Component as unknown as { displayName?: string }).displayName + if (!name) { + throw new Error( + 'Component has no name. May be a React.MemoExoticComponent-- if so, set displayName on the component.' + ) + } + return name +}