Replace TS any with Generics / Concrete Types — Step-by-Step TODO
Status: Done (2026-07-19) · Author: AI Review · Date: 2026-07-17
Audit of every @typescript-eslint/no-explicit-any suppression in production
code (src/). Where a generic type parameter is genuinely the right fix
(withCache), use generics; everywhere else, use the concrete types that
already exist in src/models (RopaType, OuType, ActivityType,
ContractType, …). Test-file as any mock casts, deadcode/, website/,
and playwright-tests/ are out of scope.
Baseline at time of audit: ~17 production any occurrences, each silenced
with an eslint-disable comment; ~60 further as any casts live in test files
and stay untouched.
Steps
-
1. Create this TODO file (
docs/dev/TODO/any-to-generics-todo.md) -
2.
src/lib/cache/withCache.ts— the one true generics candidate. Replace(...args: any[])with a generic argument tuple so all wrapped service functions keep their parameter types at call sites (today the returned wrapper is(shortName: string, ...rest: unknown[]), so arguments beyond the first are unchecked):export const withCache = <Args extends [string, ...unknown[]], T>(fn: (...args: Args) => Promise<ServiceResult<T>>,opts: Record<string, unknown> = {},) =>cache(async (...args: Args) => {const cacheTag = String(args[0]);// ... unstable_cache callback becomes (...innerArgs: Args)});The
[string, ...unknown[]]constraint is safe: every wrapped_fntakesshortName: stringfirst (verified acrossorganizationService,ropaService,templateService). Keep runtime behavior identical (tag derivation, arg pass-through). Remove the eslint-disable. PerAGENTS.md, read theunstable_cache/ Reactcachedocs innode_modules/next/dist/docs/first. Regression check:organization.data!.ropasinsrc/lib/mongoose/ouDelete.tsmust still typecheck (provesTinference is unchanged).Done 2026-07-19. The generic tuple exposed 21 call-site errors the old
...rest: unknown[]signature had hidden. Fixed alongside:_getRopaByIdROid param widened tostring | ObjectId | null | undefined(RopaIdInput) — callers pass populatedropas.ropaIdObjectIds;ensureRopaIdalready handles null/undefined at runtime._readTemplateROtemplateId +templateService.validateObjectIdwidened tostring | ObjectIdfor the same reason._listOrganizationLanguagesROlocales →readonly string[](routing.locales is a readonly tuple).- Latent bug fixed:
organizationTemplateGet.tscalledreadTemplateROwith only the templateId (no shortName), so the templateId occupied the cache-tag slot andtemplateIdwasundefined— the DB-template branch always failed and silently fell back to file templates. Both calls now passshortNamefirst.
-
3.
src/services/templateService.ts(~line 746) — drop the_reloadTemplateRO as (...args: any[]) => Promise<ServiceResult<unknown>>cast, made unnecessary by step 2. IfTinference still fails (union return type), add an explicit return type to_reloadTemplateROinstead of casting.Done 2026-07-19. Inference did fail on the union (one error path widened
isErrortoboolean), so_reloadTemplateROnow has an explicitPromise<ServiceResult<ReloadTemplateData>>return type (structural interface covering all helper variants). The now-checked call ine/tmp/[id]/page.tsxneededtype!(post-redirect guard, same idiom the file already used further down). -
4.
src/lib/ui/MainPage/ShowActivity.tsx(4 anys) —findActivityInRopa(ropa: any)receives a populatedropas.ropaId(see_getFullOrgByShortNameRO's.populate("ropas.ropaId")), whose static Mongoose type is an ObjectId, soRopaTypewon't flow from the caller. Type the param with a minimal structural type, e.g. a localtype RopaLike = { ous?: (OuType & { activities?: ActivityType[] })[] }(orPick<RopaType, "ous">if compatible) plus one documented cast where the populated value is extracted. Then:(act: any)infers from the typedactivities;activityId: any→number | string(fed toisNaN/Number);attributesShown?: any→string[](ActivityDetailsdeclaresattributesToShow: string[],src/lib/ui/modals/ActivityDetails.tsx:122).Done 2026-07-19, with deviations found during implementation:
activityId→number(notnumber | string): both pages passNumber(id), and TS'sisNaNonly acceptsnumberanyway.Pick<RopaType, "ous">is NOT compatible —InferSchemaTypeyields hydratedSubdocumentarrays, incompatible with the plainRecord<string, unknown>[]thatActivityDetails.ActivityLocaleexpects. Used a fully structuralPopulatedRopa(_id+ousofPick<OuType, "ouId" | "ouColor">withactivities?: Record<string, unknown>[]), single documented cast at theropaByLocalebuild.attributesShown→ requiredstring[]; the constants are readonlyas consttuples, so both pages spread them ([...attributesShownInEditActivity]), same idiom ase/new/page.tsx.originalOuColorpassesouColor ?? undefined(exactOptionalPropertyTypes); anullouColor now falls back to ActivityDetails'"#000"default instead of flowing through asnull.ActivityDetailsProps.originalOuColorwidened tostring | undefinedto accept the explicit undefined.
-
5.
src/lib/ui/cards/ContractCaption.tsx(5 anys) +ContractCard.tsx(1 any) —contract.partnerIds/activityIdsarenumber[]onContractType→ replaceas any[]withas number[] | undefined(keep theRecord<string, unknown>contract prop; don't widen the refactor). ThepartnerOrgs: any[]/activityItems: any[]accumulators exist because.filter(Boolean)doesn't narrow — use a type-guard filter.filter((x): x is NonNullable<typeof x> => Boolean(x))and let element types infer frompartners.find(...)/flat.find(...). Type theflatMap((ou: any) => …)chain viaOuType/ActivityTypestructural picks consistent withuseRopa()'s return type (check it first).Done 2026-07-19.
useRopa(): RopaTypeis already typed, so the flatMap chain needed no structural picks — plain inference works.partnerIds/activityIds→as number[] | undefined; type-guard filters as planned. Two?? undefinedadded onElementChip label/color(schema-inferredstring | null | undefinedvsstring | undefinedprops). -
6.
src/lib/ui/buttongroups/OuFormButtons.tsx:39—ouId: any→number(matchesouDelete(ouId: number),src/lib/mongoose/ouDelete.ts). CheckOuFormButtonscall sites pass a number; coerce at the caller if it arrives as a URL-param string.Done 2026-07-19. Single caller (
OuForm.tsx) already declaresouId?: numberwith default 0 — no coercion needed. -
7.
src/services/importService.ts:80—const sanitized: any = sanitizeEnvelope(json):sanitizeEnvelopereturnsunknown; aftervalidateEnvelopepasses, the shape isImportEnvelope(z.infer, exported fromsrc/lib/schemas/importSchema.ts). Smallest fix: narrow once after the validation guard (const envelope = sanitized as ImportEnvelope) — or, cleaner, havevalidateEnvelopereturn the parsed data.Done 2026-07-19 with the smallest fix (single narrowing cast after the guard). Knock-on:
decodeOrganizations' param needed an explicit| undefinedonorganizationLogo(exactOptionalPropertyTypes vs zod's inferred optional). -
8.
src/services/mongodb.ts:81—session.withTransaction(work, opts as any): type the parameteropts?: TransactionOptions(import type { TransactionOptions } from "mongodb", mongoose's underlying driver) and drop the cast. PerAGENTS.md, fetch the mongoose docs forwithTransactionfirst.Done 2026-07-19. Verified against mongoose transactions doc and the installed driver types (
mongodb.d.ts:withTransaction(fn, options?: TransactionOptions & { timeoutMS? })). No caller passesoptstoday, so no call-site fallout. -
9. Final test step (i–iii run by hand 2026-07-19; iv: only the three documented leave-as-is casts + test mocks remain):
npx tsc --noEmit— must be clean.- Lint (
npm run formatuses eslint; run plainnpx eslint .) — confirms the removedeslint-disabledirectives aren't orphaned. npx vitest run— full unit suite; watchsrc/services/*.test.ts,src/lib/ui/**,importSchema.test.ts.- Grep to confirm no production
: any/as anyremains outside the documented leave-as-is list below.
-
10. Final pass: add concise TSDoc/inline comments to the modified TS files where the new typing isn't self-evident —
withCache'sArgs/Tparameters and the[string, ...unknown[]]shortName constraint, the structural ropa type rationale inShowActivity.tsx(populated ref vs. static ObjectId type), the single narrowing cast inimportService.ts. Match each file's existing comment density. Do NOT add docusaurus documentation for this work.
When all boxes are ticked: set Status above to Done, commit, and push.
Deliberately left as any (with reasons)
| Location | Reason |
|---|---|
src/lib/cache/cacheInvalidate.ts | revalidateTag's second argument (cache profile) is not present in the installed Next.js type definitions; the cast is a deliberate forward-compat call. Re-verify against node_modules/next/dist/docs/ — remove the cast only if the installed version types it |
src/lib/ui/buttongroups/ButtonStack.tsx | Renders heterogeneous button components dynamically; cast is documented as intentional. A discriminated-union refactor of the items type is a separate work item |
src/lib/ui/buttons/ButtonOpenLinkNewTab.tsx | MUI polymorphic component prop typing limitation; touch only if trivially removable (consult mui-mcp per AGENTS.md) |
*.test.ts / *.test.tsx | as any on mocks is conventional test plumbing; out of scope |
deadcode/, website/, playwright-tests/ | Not production app code |
JSDoc mentions of any (e.g. src/services/ropaService.ts param docs) | Comment text, not type-level any; optional cosmetic tidy |