Skip to content

Typing Conventions

Rules for any, unknown, casts, and generated Prisma types. Read this when you reach for any or are about to annotate a query result.

The codebase does not enable tsconfig strict: true (would cascade churn). It enforces a smaller perimeter: strictNullChecks + noImplicitAny from the compiler, and @typescript-eslint/no-explicit-any: 'error' from ESLint (scoped off in spec files only). The rules below cover what those settings cannot.


1. Prisma.XGetPayload<{ include: typeof K }> — where it belongs

Generated payload types are reserved for contract boundaries, not for annotating intermediate query results.

Belongs: - The TRecord generic on BaseTenantedCrudService subclasses (e.g. StudentWithRelations, DepartmentWithCounts). - Lookup / cross-module contracts exported from interfaces/ (e.g. ImportLookupData carrying a payload-typed field).

Does not belong: - Inline annotation of intermediate query results inside service methods. Inference already covers them.

// CORRECT — let inference do the work
const rows = await this.prisma.curriculum.findMany({
  where: { tenantId, academicYearId },
  include: curriculumListInclude,
});
// rows: typed by Prisma based on the include literal

// WRONG — noisy redundant annotation
const rows: Prisma.CurriculumGetPayload<{ include: typeof curriculumListInclude }>[] =
  await this.prisma.curriculum.findMany({ ... });

The TRecord generic is the one place where the payload type pulls its weight: it ties getScopeFieldMappings() to the actual fields the include resolves, so scope field arrays are statically checked against the record shape.


2. Response DTO contract

Response DTO fields must reference real DTO classes. unknown[] and Swagger type: 'object' on a response field are bugs, not shortcuts.

// WRONG
@ApiProperty({ type: 'array', items: { type: 'object' } })
studyPlans: unknown[];

// CORRECT
@ApiProperty({ type: [StudyPlanConfigResponseDto] })
studyPlans: StudyPlanConfigResponseDto[];

If a field is not modeled yet, model it. If it is genuinely freeform JSON (custom-field values, JSONB columns), use Record<string, unknown> with a one-line comment naming why it cannot be a DTO. Custom-field maps are the canonical example.

This catches Swagger-only bugs the compiler cannot: the response DTO is the API contract for the frontend, and type: 'object' produces an unusable schema.


3. Assembling Prisma data payloads from optional DTOs

When building a Prisma data object from optional DTO fields, use pickDefined (src/common/utils/pick-defined.ts). It strips undefined keys, preserves null, and returns a typed Partial<T> — no Record<string, unknown> accumulator, no hand-rolled guard chains.

// CORRECT
const data = pickDefined({
  name: dto.name,
  startDate: dto.startDate,
  endDate: dto.endDate,
});

// WRONG — untyped accumulator, repetitive guards
const data: Record<string, unknown> = {};
if (dto.name !== undefined) data.name = dto.name;
if (dto.startDate !== undefined) data.startDate = dto.startDate;
if (dto.endDate !== undefined) data.endDate = dto.endDate;

// WRONG — conditional-spread idiom, scales badly past ~3 fields
const data = {
  ...(dto.name !== undefined && { name: dto.name }),
  ...(dto.startDate !== undefined && { startDate: dto.startDate }),
};

For DTOs with scope-grouped sub-objects (e.g. dto.anagraphic, dto.contacts, dto.documents), spread the groups inside pickDefined:

const fields = pickDefined({
  ...(dto.anagraphic ?? {}),
  ...(dto.contacts ?? {}),
  ...(dto.documents ?? {}),
});

Prisma treats an undefined value in a data payload as "leave the column alone" — so stripping undefined is observationally equivalent to omitting the key. The win is making the intent explicit and getting a typed Partial<T> instead of Record<string, unknown>.

When pickDefined doesn't earn its keep. For 2–3 isolated scalar copies interleaved with side-effect logic (validation calls, async lookups, conditional branches), the guard-chain form stays clearer — custom-fields.service.ts.update() is the canonical example. Reach for pickDefined when you have a clean group of optional fields, not when the assignments are entangled with control flow.

For BaseTenantedCrudService subclasses. Prefer the inherited flattenDto over a direct pickDefined call — it walks getScopeFieldMappings() and dev-asserts unregistered scope keys, which pickDefined cannot do.


4. catch (error: unknown) narrowing

Always annotate the caught variable as unknown, then narrow before use. Never type the catch as any.

try {
  await this.prisma.curriculum.create({ data });
} catch (error: unknown) {
  if (
    error instanceof Prisma.PrismaClientKnownRequestError &&
    error.code === 'P2002'
  ) {
    throw new AppException(
      ErrorCode.DUPLICATE,
      'Curriculum name already exists',
      HttpStatus.CONFLICT,
    );
  }
  throw error;
}

Acceptable narrowings: instanceof Prisma.PrismaClientKnownRequestError, instanceof AppException, instanceof Error. If you cannot narrow, rethrow — let AllExceptionsFilter format it.


5. When as any is acceptable

Only at external-library boundary shims where the library's published type is wrong, missing, or unreachable. Two examples that legitimately ship in this codebase:

// @nestjs/jwt expects `ms.StringValue`, not `string`
expiresIn: this.config.getOrThrow('JWT_EXPIRY') as any,

Every as any outside **/*.spec.ts requires a one-line comment naming the library and the quirk. The reviewer's job is to ask "can this be a typed shim?" before approving.

Never valid targets for as any: - Business data flowing through services (DTOs, entities, query results) - Permission / scope structures - Anything inside controllers - Mocked Prisma surfaces in non-spec code

If you find yourself reaching for as any on business data, the right move is one of: - Add the missing field/type to the DTO or interface - Narrow with instanceof or a discriminated union - Use as unknown as T only when the runtime contract genuinely diverges from the static type (the toScopedResponse() cast is the canonical example, see §7 below)


6. Spec files

*.spec.ts, *.e2e-spec.ts, and test/**/*.ts are exempt from no-explicit-any, no-unsafe-argument, no-unsafe-assignment, no-unsafe-member-access, no-unsafe-return, and no-unsafe-call. The override lives in eslint.config.mjs.

This means the following are all legal in spec files:

let prisma: any;
const result = await service.create(input as any);
prisma.$transaction.mockImplementation((fn: any) => fn(prisma));

Writing fully-typed mocks for every Prisma surface is not worth the churn at this codebase's size. The exemption is a deliberate trade-off, not laziness.

The blanket /* eslint-disable */ at the top of older spec files is no longer required after the scoped override. Remove it next time you touch the file for another reason — do not open a PR purely to strip the comments.


7. Trade-off — the base service is any at the delegate boundary

BaseTenantedCrudService.getPrismaDelegate(client?: any): any is deliberate. Two facts force this shape:

  1. Prisma's generated delegate types are model-specific. Prisma.StudentDelegate, Prisma.DepartmentDelegate, etc. share no usefully-typed structural supertype. Their findFirst/create/update/delete arguments reference per-model fields.
  2. The base must call those delegate methods generically. Any constraint that lets the base call .findFirst({ where: { tenantId, ... } }) either collapses argument typing to any or forces the codebase to duplicate Prisma's conditional-type generator.

The current shape is the least-bad trade. The any is a boundary, not a leak — subclasses override getPrismaDelegate to return this.prisma.<model>, which is fully inferred at the call site:

// In StudentsService — typed delegate at the call site
protected getPrismaDelegate(client?: any) {
  return (client ?? this.prisma).student; // Prisma.StudentDelegate (inferred)
}

Subclass code that calls into Prisma directly (lifecycle hooks, custom service methods) keeps full Prisma typing. Only the base class itself sees any, and that scope is bounded.

The toScopedResponse() as unknown as TResponseDto cast has the same justification: scope discovery is dynamic (custom fields contribute scopes at runtime via CustomFieldsService.definitionsByScope), so the assembled response shape cannot match a static DTO at the type level. The cast encodes the runtime contract that the DTO declares.

If a future audit re-flags either of these, point it at this section.


8. Rule of thumb

When you reach for any, ask in order: 1. Is this a spec file? → Allowed. 2. Is this an external-library boundary shim with a one-line comment? → Allowed. 3. Is this the base service's delegate boundary? → Already documented. 4. Otherwise → It's a bug. Model the type, narrow with instanceof, or use unknown and narrow.

When you reach for Prisma.XGetPayload<...>, ask: 1. Is this the TRecord generic on a base service subclass? → Yes. 2. Is this a cross-module contract in interfaces/? → Yes. 3. Otherwise → Drop it. Inference is already correct.