# Type Safety Audit

Scope: `/Users/Tommaso/Desktop/bitsocial/5chan-codebase-audit/src/` (~50k LOC, 366 `.ts`/`.tsx` files, `src/generated/` excluded). Read-only pass on source.

## Summary

Rough counts over `src/` excluding `src/generated/`:

| Signal | Count | Notes |
|---|---:|---|
| `: any` / `<any>` / `as any` (all) | 79 | 33 in non-test production code |
| `as any` in production code | 9 | almost entirely for untyped `window.*` globals or library result reshapes |
| `@ts-ignore` | 0 | clean |
| `@ts-expect-error` | 1 | single test-only use in `src/lib/utils/__tests__/misc-utils.test.ts:146`, justified |
| `@ts-nocheck` | 0 | clean |
| Non-null assertions `!` (production, excluding `!=` / `!==`) | ~15 | a few risky ones on `.getAttribute(...)`, `reader` and `e.target`, rest are mostly bailouts for hook-returned shapes |
| `as unknown as T` casts | 4 in production | all in `use-post-page-number.ts`, `account-data-editor.tsx` - library shape crutches |
| Broad `as string` / `as number` casts | 5 in production | mostly around `react-router` params (`useParams()` returns `Readonly<Params<string>>`) |
| Untyped event handlers (`(e) => e.target.value`) | ~34 call sites | inference usually works, but the pattern is consistent throughout forms |
| `: Function` erased-shape types | 1 | `src/components/post-form/post-form.tsx:337` |
| `@ts-ignore` escape hatches | 0 | a strong signal - the codebase does not lean on ignores |

The overall state is good: **no `@ts-ignore`, no `@ts-nocheck`**, and explicit `any` in production is isolated to a small number of repeatable patterns (library interop with `bitsocial-react-hooks`, `window.electronApi` extensions, `onChallenge: (...args: any[])` callbacks). Most of the risk is concentrated in a few hot files - `post-desktop.tsx`, `post-mobile.tsx`, `use-post-page-number.ts`, `media-utils.ts`, and `challenge-utils.ts`.

## Findings

### Critical

- **src/lib/utils/media-utils.ts:195, 201** - `ogImage.getAttribute('content')!` and `firstImage.getAttribute('src')!` assert non-null on values that can legitimately be `null` (the attribute may be present as an empty string or may have been removed between the guard and the read). This is inside `fetchWebpageThumbnail`, a user-input (URL) driven code path. *Fix:* capture into a local `const content = ogImage.getAttribute('content'); if (!content) return undefined;`.
- **src/lib/utils/media-utils.ts:182** - `await reader!.read()` inside a `while (true)` loop assumes `response.body?.getReader()` returned. If `response.body` is `null` (fetch bodies can be null when the server returns no body) this throws an uninformative runtime error instead of falling back to the no-thumbnail path. *Fix:* guard `if (!reader) return undefined;` before the loop.
- **src/components/settings-modal/account-settings/account-settings.tsx:123** - `const fileContent = e.target!.result;` on a `FileReader.onload` handler. `e.target` is typed `FileReader | null`. This is user-driven (import account JSON), and while `onload` almost always has a target, the code should check `e.target?.result` and bail with the existing alert UI. *Fix:* `const fileContent = e.target?.result;` then preserve the `typeof` guard that already exists below.
- **src/hooks/use-post-page-number.ts:35-38, 60** - Four non-null assertions plus two `as unknown as` casts on Zustand store shape: `state.feedsOptions as unknown as FeedsOptionsLike`, `communityAddress!`, `postCid!`. The `canResolve` boolean does guard them, but the `as unknown as` pair bypasses type checking on shared store state that is heavily used across the app. If the upstream `bitsocial-react-hooks` shape changes, TS will silently accept it. *Fix:* Define a narrow local type that re-declares the slice you consume from `useFeedsStore.getState()`, and either derive a discriminated `{ canResolve: true; communityAddress: string; postCid: string } | { canResolve: false }` or pass through `as string` after the guard instead of `!`.

### High

- **src/components/post-desktop/post-desktop.tsx:879-910 and src/components/post-mobile/post-mobile.tsx:604-629** - Repeated `(fooResult as { updatedReplies?: Comment[] }).updatedReplies!` pattern (6 occurrences). The cast-then-assert pattern hides whether `useReplies()` actually returns `updatedReplies`. If the upstream hook drops or renames the field, TS will not catch it and the `!` will silently yield `undefined` which is then used in `.length`-style checks elsewhere. *Fix:* extend `bitsocial-react-hooks` ambient type augmentation (`src/modules.d.ts`) to declare `updatedReplies?: Comment[]` on the `useReplies` return type, so the three call sites can drop the casts entirely.
- **src/lib/utils/challenge-utils.ts:15, 47, 63, 76** - Four exported functions take `publication: any`: `alertChallengeVerificationFailed`, `getPublicationType`, `getVotePreview`, `getPublicationPreview`. These are the central challenge-failure code path, called from **every** `onChallengeVerification` handler in the app (publish post, publish reply, moderation, delete). The shape is actually known: it is the `PublishCommentOptions`-like payload. *Fix:* Introduce a `type ChallengePublication = { vote?: number; parentCid?: string; commentCid?: string; title?: string; content?: string; link?: string; subplebbitAddress?: string; communityAddress?: string; }` and replace the four `any` parameters. Fallbacks (`publication?.vote`) already tolerate missing fields; the type just codifies that.
- **src/views/post/post.tsx:137, 139** - `PostProps.post?: any` and `PostProps.reply?: any` on the top-level exported `Post` component. This leaks `any` through the main post rendering surface into every `post-desktop.tsx` / `post-mobile.tsx` reader; downstream code does many `post?.cid`, `post?.content` accesses with no checking. *Fix:* use `Comment | undefined` (or a project-local `ResolvedPost` alias if the component accepts richer shapes) - this is the single highest-leverage `any` removal in the codebase.
- **src/components/post-mobile/post-mobile.tsx:444** - `const PostMediaContent = ({ post, link }: { post: any; link: string })` in a hot render path. *Fix:* `post: Comment | undefined` (same change as above, one file).
- **src/components/catalog-row/catalog-row.tsx:30** and **src/components/markdown/markdown.tsx:30, 32** - Public component props typed as `commentMediaInfo: any`, `children: any`, `linkMediaInfo: any`. `CommentMediaInfo` is already exported from `src/lib/utils/media-utils.ts` and is the right type. *Fix:* import and use `CommentMediaInfo`. For `children`, use `React.ReactNode`.
- **src/hooks/use-communities-stats.ts:7, 8** - `communityStats: { [communityAddress: string]: any }` and `setCommunityStats: (address, stats: any) => void` on a store. Consumers read `stats.allPostCount` etc.; the type is knowable from `useCommunityStats`'s return. *Fix:* import `CommunityStats` type from `bitsocial-react-hooks` or declare a narrow `{ allPostCount?: number; ... }` interface.
- **src/hooks/use-stable-community.ts:24, 41, 64** - `Record<string, any>` in `shallowEqual`, `(prev: any, next: any)` in `isCommunityEqual`, and `selector: (community: any) => T` in `useCommunityField`. The value is a `Community`, which is imported at the top of the file but then abandoned. *Fix:* replace the `any` with `Community` (or `Partial<Community>` for `isCommunityEqual`); `shallowEqual` can be `<T extends Record<string, unknown>>(a?: T, b?: T)`.
- **src/stores/use-publish-post-store.ts:8** and **src/stores/use-publish-reply-store.ts:8** - `author?: any | undefined` (the `| undefined` is redundant when already optional). Author shape is `Author` or at least `{ displayName?: string; ... }`. The stored object is assembled from `Comment.author` a few lines below. *Fix:* `author?: Comment['author']`.
- **src/components/post-desktop/post-desktop.tsx:110, 129; post-mobile.tsx:124, 143; use-publish-post.ts:76; use-publish-reply.ts:125; use-delete-failed-post.ts:86; mod-queue.tsx:217, 236; edit-menu.tsx:63** - Ten `onChallenge: async (...args: any[]) => ...` callbacks. These all follow the same `(...args) => addChallenge([...args, post])` pattern; `addChallenge` is a Zustand action in `use-challenges-store.ts`. Whatever shape `args` takes is fixed by the upstream hook. *Fix:* declare a `type ChallengeCallback = NonNullable<PublishCommentOptions['onChallenge']>` once, then reuse it at every site; `(...args: Parameters<ChallengeCallback>)` makes the list type-safe.

### Medium

- **src/components/post-form/post-form.tsx:337** - `debounce((content: string, t: Function) => ...)`. `Function` erases all information. *Fix:* `t: TFunction` imported from `'i18next'` (already used via `useTranslation()` elsewhere in the same file).
- **src/lib/utils/media-utils.ts:18** - `getDisplayMediaInfoType(type: string, t: any)`. Same fix: `t: TFunction`. Used by multiple callers that already hold a typed `t`.
- **src/lib/utils/clipboard-utils.ts:6, 8** - `(window as any).electronApi?.copyToClipboard`. `window.electronApi` is already declared in `src/globals.d.ts`, so the cast is entirely unnecessary and actively suppresses the declared contract. *Fix:* remove `as any`; `window.electronApi?.copyToClipboard` already typechecks.
- **src/lib/react-scan.ts:5, 12, 25, 26, 71** - Dev-only integration (`import.meta.env.DEV` guarded). Five `as any`, including a container object declared as `const elementSourceApi: any`. Because this only runs in DEV it is low runtime risk, but it masks the real API surface. *Fix:* declare the extra window globals (`__PROFILING__`, `__getReactScanReport`, `__ELEMENT_SOURCE__`) in `src/globals.d.ts`; type `elementSourceApi` as a concrete interface that both branches (`notReady` stubs and the resolved-module assignment) conform to.
- **src/hooks/use-account-communities-with-metadata.ts:11, 12** - `(community as any).address`, `(community as any).title` inside a `.map()`. The iterated value is `AccountCommunity` from `useAccountCommunities`. *Fix:* remove the casts or narrow once at the top of the map: `const c = community as { address?: string; title?: string };`.
- **src/stores/use-catalog-filters-store.ts:369** - `partialize: (state) => ({...}) as any` inside a Zustand `persist` config. The `as any` likely exists because the partialized shape is narrower than the full state. *Fix:* type the return as `Pick<CatalogFiltersStore, 'filterItems'>` or use `partialize`'s generic properly; Zustand's `PersistOptions<S, Partial<S>>` handles this without the cast.
- **src/components/catalog-filters/catalog-filters.tsx:118** and **src/components/catalog-filters/highlight-color-picker/highlight-color-picker.tsx:6-9** - `item: any`, `updateLocalFilterItem: (index: number, item: any) => void`, `localFilterItems: any[]` - the `FilterItem` interface is already exported in `use-catalog-filters-store.ts`. *Fix:* import and use `FilterItem` (or a `LocalFilterItem` subset that omits Map/Set state).
- **src/components/edit-menu/edit-menu.tsx:63** - `onChallenge = useCallback((...args: any) => addChallenge([...args, latestPostRef.current]), [])`. Note also that `(...args: any)` is a rest with value-type `any` (worse than `...args: any[]`). *Fix:* see the `ChallengeCallback` pattern in the High section; at minimum use `any[]`.
- **src/components/post-form/post-form.tsx:401** and **:536** - `const cid = params?.commentCid as string;` and `state.comments[commentCid as string]`. The cast is applied before any null check, so downstream code sees a non-nullable string that might actually be `undefined`. *Fix:* `const cid = params?.commentCid; if (!cid) return null;` - or type the destructured param properly.
- **src/lib/utils/pretext-height-estimates.ts:878** - `Math.round(estimate as number)` after `estimate` was presumably typed as `number | undefined`. Rounding `undefined` produces `NaN` silently, then feeds `actualHeight - numericEstimate`. *Fix:* narrow with an explicit `if (typeof estimate !== 'number') return;` earlier.
- **src/hooks/use-directories.ts:253, 267, 268** - `getDirectoriesMetadata(directoriesData as unknown)` and `normalizeDirectoriesData(directoriesData as unknown)` - casting **to** `unknown` (not `as unknown as T`). These are effectively no-ops and may be leftovers from a previous refactor. *Fix:* drop the casts or make the functions' parameters `unknown` explicitly.
- **src/stores/use-catalog-filters-store.ts:265** - `communityFilteredCids.get(communityAddress)!` immediately after `if (!...has) { set(..., new Set()); }`. Correct logic, but the non-null assertion is readable. *Fix:* use the object returned from `set()` pattern: `let cidSet = communityFilteredCids.get(communityAddress); if (!cidSet) { cidSet = new Set(); communityFilteredCids.set(communityAddress, cidSet); }`.

### Low

- **src/components/embed/embed.tsx:145** - `margin: 0!important;` - this is a CSS-in-JS string, not a TS non-null. Listed only because grep-based scans will flag it; no action.
- **src/views/faq/faq.tsx:192** and **src/components/directory-modal/directory-modal.tsx:40** - exclamation points are inside JSX text content. No action.
- **src/hooks/use-directories.ts:311** - `throw new Error(`HTTP error! status: ${response.status}`)` - exclamation is a literal. No action.
- **src/components/boards-bar/boards-bar.tsx:342-343** - `extractDirectoryFromTitle(board.title!)` and `directoryCode!`, `board.title!`. Line 313 filters the array by `sub.title && extractDirectoryFromTitle(sub.title)`, so both values are guaranteed non-null at this point, but the guarantee is spatially distant. *Fix:* compute `{value, label}` objects in the `.filter` pass so the type narrows naturally, or capture into a local variable and narrow with an explicit `if`.
- **src/components/board-buttons/board-buttons.tsx:273, 363, 430** - `onChange={(e) => setX(e.target.value as 'Small' | 'Large')}` style on `<select>`. Since `<option>` values are themselves strings, TS won't verify the union - a stray option would slip through. *Fix:* centralize the options array as `const IMAGE_SIZES = ['Small', 'Large'] as const` and use it both for the `<option>` list and the cast source, or switch to a guarded assertion function.
- **src/views/account-data-editor/account-data-editor.tsx:11, 20, 35** - `React.ComponentType<any>` for the dynamically imported `react-ace` editor. Legit library-interop `any`; `react-ace`'s props API is broad. Leave; annotate as `AceEditor?: React.ComponentType<IAceEditorProps> | null` if `react-ace` exposes it.
- **36 onChange handlers** of the form `(e) => setX(e.target.value)` across `board-buttons.tsx`, `interface-settings.tsx`, `crypto-wallets-setting.tsx`, `post-form.tsx`, `catalog-filters.tsx`, etc. - These **do** typecheck via inference from the JSX `<input onChange>` contract, so `e` is `React.ChangeEvent<HTMLInputElement>` implicitly. This is stylistic only; not a real safety problem. Leave unless a stricter house style is adopted.
- **Test files** - 20+ `as any` / `as unknown as` in `__tests__` (e.g. mocking `createElement(Component, props as any)` and stubbing `scrollIntoView as any`). These are standard Vitest/React-test reshape patterns and do not hide production bugs. Skip.

## Top 5 Actions

1. **Type `alertChallengeVerificationFailed` and friends in `src/lib/utils/challenge-utils.ts` properly.** Four `any` removals, called from every publish/moderate code path. Ripples upward into `post-desktop.tsx`, `post-mobile.tsx`, `mod-queue.tsx`, and the three publish hooks.
2. **Replace `post?: any` / `reply?: any` in `src/views/post/post.tsx` `PostProps`.** Single type change that forces correct annotations in the two biggest files in the app. Expect a handful of collateral fixes in `post-desktop.tsx` / `post-mobile.tsx` (those files already import `Comment` from `bitsocial-react-hooks`, so the cost is small).
3. **Declare `updatedReplies?: Comment[]` on `useReplies()`'s return type via `src/modules.d.ts` augmentation.** Removes six `as { updatedReplies?: Comment[] }).updatedReplies!` casts in `post-desktop.tsx` and `post-mobile.tsx`. Brittle upstream-shape guessing becomes a declared contract.
4. **Fix the three concrete runtime-risk non-null assertions in `src/lib/utils/media-utils.ts:182/195/201` and `src/components/settings-modal/account-settings/account-settings.tsx:123`.** All four are reachable from user input (link preview fetch, JSON import) and each can realistically throw on a malformed response or empty `FileReader`. Two-minute edits, real stability win.
5. **Introduce a single `ChallengeCallback` alias for `onChallenge` handlers.** Ten `(...args: any[])` / `(...args: any)` rest-callback sites in publish, moderation, and edit flows collapse to one type import. This is the most widespread uniform `any` pattern in the codebase; fixing it once removes nearly a third of production `any` usage.
