Skip to main content

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 _fn takes shortName: string first (verified across organizationService, ropaService, templateService). Keep runtime behavior identical (tag derivation, arg pass-through). Remove the eslint-disable. Per AGENTS.md, read the unstable_cache / React cache docs in node_modules/next/dist/docs/ first. Regression check: organization.data!.ropas in src/lib/mongoose/ouDelete.ts must still typecheck (proves T inference is unchanged).

    Done 2026-07-19. The generic tuple exposed 21 call-site errors the old ...rest: unknown[] signature had hidden. Fixed alongside:

    • _getRopaByIdRO id param widened to string | ObjectId | null | undefined (RopaIdInput) — callers pass populated ropas.ropaId ObjectIds; ensureRopaId already handles null/undefined at runtime.
    • _readTemplateRO templateId + templateService.validateObjectId widened to string | ObjectId for the same reason.
    • _listOrganizationLanguagesRO locales → readonly string[] (routing.locales is a readonly tuple).
    • Latent bug fixed: organizationTemplateGet.ts called readTemplateRO with only the templateId (no shortName), so the templateId occupied the cache-tag slot and templateId was undefined — the DB-template branch always failed and silently fell back to file templates. Both calls now pass shortName first.
  • 3. src/services/templateService.ts (~line 746) — drop the _reloadTemplateRO as (...args: any[]) => Promise<ServiceResult<unknown>> cast, made unnecessary by step 2. If T inference still fails (union return type), add an explicit return type to _reloadTemplateRO instead of casting.

    Done 2026-07-19. Inference did fail on the union (one error path widened isError to boolean), so _reloadTemplateRO now has an explicit Promise<ServiceResult<ReloadTemplateData>> return type (structural interface covering all helper variants). The now-checked call in e/tmp/[id]/page.tsx needed type! (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 populated ropas.ropaId (see _getFullOrgByShortNameRO's .populate("ropas.ropaId")), whose static Mongoose type is an ObjectId, so RopaType won't flow from the caller. Type the param with a minimal structural type, e.g. a local type RopaLike = { ous?: (OuType & { activities?: ActivityType[] })[] } (or Pick<RopaType, "ous"> if compatible) plus one documented cast where the populated value is extracted. Then: (act: any) infers from the typed activities; activityId: anynumber | string (fed to isNaN/Number); attributesShown?: anystring[] (ActivityDetails declares attributesToShow: string[], src/lib/ui/modals/ActivityDetails.tsx:122).

    Done 2026-07-19, with deviations found during implementation:

    • activityIdnumber (not number | string): both pages pass Number(id), and TS's isNaN only accepts number anyway.
    • Pick<RopaType, "ous"> is NOT compatible — InferSchemaType yields hydrated Subdocument arrays, incompatible with the plain Record<string, unknown>[] that ActivityDetails.ActivityLocale expects. Used a fully structural PopulatedRopa (_id + ous of Pick<OuType, "ouId" | "ouColor"> with activities?: Record<string, unknown>[]), single documented cast at the ropaByLocale build.
    • attributesShown → required string[]; the constants are readonly as const tuples, so both pages spread them ([...attributesShownInEditActivity]), same idiom as e/new/page.tsx.
    • originalOuColor passes ouColor ?? undefined (exactOptionalPropertyTypes); a null ouColor now falls back to ActivityDetails' "#000" default instead of flowing through as null. ActivityDetailsProps.originalOuColor widened to string | undefined to accept the explicit undefined.
  • 5. src/lib/ui/cards/ContractCaption.tsx (5 anys) + ContractCard.tsx (1 any) — contract.partnerIds / activityIds are number[] on ContractType → replace as any[] with as number[] | undefined (keep the Record<string, unknown> contract prop; don't widen the refactor). The partnerOrgs: 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 from partners.find(...) / flat.find(...). Type the flatMap((ou: any) => …) chain via OuType/ActivityType structural picks consistent with useRopa()'s return type (check it first).

    Done 2026-07-19. useRopa(): RopaType is already typed, so the flatMap chain needed no structural picks — plain inference works. partnerIds/activityIdsas number[] | undefined; type-guard filters as planned. Two ?? undefined added on ElementChip label/color (schema-inferred string | null | undefined vs string | undefined props).

  • 6. src/lib/ui/buttongroups/OuFormButtons.tsx:39ouId: anynumber (matches ouDelete(ouId: number), src/lib/mongoose/ouDelete.ts). Check OuFormButtons call 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 declares ouId?: number with default 0 — no coercion needed.

  • 7. src/services/importService.ts:80const sanitized: any = sanitizeEnvelope(json): sanitizeEnvelope returns unknown; after validateEnvelope passes, the shape is ImportEnvelope (z.infer, exported from src/lib/schemas/importSchema.ts). Smallest fix: narrow once after the validation guard (const envelope = sanitized as ImportEnvelope) — or, cleaner, have validateEnvelope return the parsed data.

    Done 2026-07-19 with the smallest fix (single narrowing cast after the guard). Knock-on: decodeOrganizations' param needed an explicit | undefined on organizationLogo (exactOptionalPropertyTypes vs zod's inferred optional).

  • 8. src/services/mongodb.ts:81session.withTransaction(work, opts as any): type the parameter opts?: TransactionOptions (import type { TransactionOptions } from "mongodb", mongoose's underlying driver) and drop the cast. Per AGENTS.md, fetch the mongoose docs for withTransaction first.

    Done 2026-07-19. Verified against mongoose transactions doc and the installed driver types (mongodb.d.ts: withTransaction(fn, options?: TransactionOptions & { timeoutMS? })). No caller passes opts today, 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):

    1. npx tsc --noEmit — must be clean.
    2. Lint (npm run format uses eslint; run plain npx eslint .) — confirms the removed eslint-disable directives aren't orphaned.
    3. npx vitest run — full unit suite; watch src/services/*.test.ts, src/lib/ui/**, importSchema.test.ts.
    4. Grep to confirm no production : any / as any remains 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's Args/T parameters and the [string, ...unknown[]] shortName constraint, the structural ropa type rationale in ShowActivity.tsx (populated ref vs. static ObjectId type), the single narrowing cast in importService.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)

LocationReason
src/lib/cache/cacheInvalidate.tsrevalidateTag'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.tsxRenders 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.tsxMUI polymorphic component prop typing limitation; touch only if trivially removable (consult mui-mcp per AGENTS.md)
*.test.ts / *.test.tsxas 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