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 configures individual compiler strictness flags rather than strict: true. 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).
- Named query contracts and cross-module interfaces that need a stable inferred payload (e.g. ImportLookupData referencing a query payload type). Define the payload type beside its include/select constant.
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 an important boundary: 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¶
Structured response fields must declare concrete DTO classes, primitives, enums or explicit unions. Do not use unknown[] or an unstructured Swagger object to hide a known contract.
// 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.CONFLICT,
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.
Prefer validation or conversion over a cast when input comes from outside the program. For example, src/config/jwt-lifetime.ts parses the configured JWT duration into a bounded whole-second lifetime; AuthModule passes numeric seconds to the signing library. A type assertion alone would not validate the configuration.
Every as any outside **/*.spec.ts requires a one-line comment naming the library and the quirk. Before accepting a boundary cast, check whether validation, conversion or a named library type can express the contract.
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));
Mock typing may be looser than production typing, but assertions should still model the actual projection and transaction behavior.
The scoped override is sufficient for mock typing. Avoid blanket /* eslint-disable */ directives so unrelated checks remain active.
7. Trade-off — the base service narrows the delegate to unknown¶
BaseTenantedCrudService.getPrismaDelegate(client?: Prisma.TransactionClient): unknown is deliberate. Two facts force this shape:
- Prisma's generated delegate types are model-specific.
Prisma.StudentDelegate,Prisma.DepartmentDelegate, etc. share no usefully-typed structural supertype. TheirfindFirst/create/update/deletearguments reference per-model fields. - The base must call those delegate methods generically. Any constraint that lets the base call
.findFirst({ where: { tenantId, ... } })either collapses argument typing toanyor 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.
Keep these casts contained at the shared boundary; domain code must retain its concrete types.
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 reusable query or cross-module contract? → Yes; define its Prisma-derived payload beside the query constants.
3. Otherwise → Drop it. Inference is already correct.