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' } })
subjects: unknown[];

// CORRECT
@ApiProperty({ type: [CurriculumSubjectResponseDto] })
subjects: CurriculumSubjectResponseDto[];

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.identity, dto.contacts, dto.documents), spread the groups inside pickDefined:

const fields = pickDefined({
  ...(dto.identity ?? {}),
  ...(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 — and even there, prefer a typed shim (as X to the real published type) over as any whenever you can name the type.

The @nestjs/jwt expiresIn case is the canonical boundary, and it ships as a typed shim, not as any:

// @nestjs/jwt's signOptions.expiresIn is typed ms.StringValue, not string —
// cast the env-derived string to the real published type
const expiresIn =
  (configService.get<string>('JWT_EXPIRATION') as StringValue) ?? ('1h' as const);

That as StringValue (from import type { StringValue } from 'ms') is the escape hatch this section endorses: narrow/cast to the library's real published type instead of any. Reach for as any only when the library exposes no nameable type to cast to.

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 — as the JWT case shows, the answer is usually yes.

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 narrows the delegate to unknown

BaseTenantedCrudService.getPrismaDelegate(client?: Prisma.TransactionClient): unknown 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 return type is deliberately unknown rather than any — callers cannot poke the raw shape; they must go through the internal delegate accessor on the base class, which re-narrows the value to PrismaDelegate<TRecord> before any method is invoked. Subclasses override getPrismaDelegate to return this.prisma.<model>, which is still fully inferred at the call site:

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

Subclass code that calls into Prisma directly (lifecycle hooks, custom service methods) keeps full Prisma typing. The unknown is a boundary, not a leak: only the base class itself ever holds the un-narrowed value, and the private delegate accessor is the single place it crosses back into typed territory.

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.