Skip to content

feat: generate Create/Update DTO classes as mapped-type compositions (makeDtoFiles) - #108

Merged
kimjbstar merged 2 commits into
mainfrom
feat/dto-mapped-types
Aug 12, 2026
Merged

feat: generate Create/Update DTO classes as mapped-type compositions (makeDtoFiles)#108
kimjbstar merged 2 commits into
mainfrom
feat/dto-mapped-types

Conversation

@kimjbstar

Copy link
Copy Markdown
Owner

Closes the last real item on the README's "Future Plan".

Why now — the stated philosophy had already been overtaken

Per-model Create/Update DTOs were deferred on the grounds that deciding what a create payload looks like belongs to the developer. That reasoning no longer matches what this generator does:

  • useValidation decides which validators are right for a field
  • useSerialization + /// @exclude decide what leaves a response
  • validateNestedRelations assumes a nested payload shape
  • a literal @default(...) already becomes a Swagger example

DTOs were the one place the old statement was still being applied. Applying the repo's actual rule produces this feature rather than blocking it.

That rule is "reflect what the schema states, never guess from a field's name" — the #34/#56/#76 Date misdetection being the cautionary tale. Which fields a client can't supply on create is stated outright in the schema, so nothing here is inferred:

field schema fact in Create?
id Int @id @default(autoincrement()) function default omitted
createdAt DateTime @default(now()) function default omitted
computed String @default(dbgenerated(...)) function default omitted
updatedAt DateTime @updatedAt isUpdatedAt omitted
author Author @relation(...) relation omitted
authorId String FK scalar kept — what a REST client actually posts
views Int @default(0) literal default kept — "there's a fallback" ≠ "you may not set it"
id String @id (no default) no default kept — the caller has to provide it

DMMF represents function defaults as an { name, args } object, which is exactly what distinguishes them from literal ones — verified against real DMMF, not assumed.

What it generates

Compositions, not copies:

// create_post.ts
import { Post } from './post'
import { OmitType } from '@nestjs/swagger'
export class CreatePost extends OmitType(Post, ['id', 'createdAt', 'updatedAt', 'author'] as const) {}

// update_post.ts
import { CreatePost } from './create_post'
import { PartialType } from '@nestjs/swagger'
export class UpdatePost extends PartialType(CreatePost) {}

A field's type, Swagger metadata and validators stay declared in exactly one place. This is deliberately a different shape from prisma-generator-nestjs-dto's fully expanded per-DTO classes — and it's the same composition the README FAQ has always told people to write by hand. Mapped types come from @nestjs/swagger when useSwagger is on, @nestjs/mapped-types otherwise.

Verified end to end, not just as strings

Generated output (models + DTOs + enums + nullable fields + useNonNullableAssertions) compiled against a real @nestjs/swagger install:

$ tsc --noEmit --strict
(no output — 0 errors)

And at runtime, the mapped types really do carry the decorators through:

1) Create에 id/createdAt/updatedAt/author 키 없음 : OK
2) Create 잘못된 값 → title(isString) views(isInt) authorId(isString) status(isEnum)
3) Create 올바른 값 → 에러 없음
4) Update {} → 에러 없음 (partial)
5) Update {title:123} → title(isString)

That exercise is also what turned up the TS1263 bug fixed separately in #106.

Implementation notes

  • ClassComponent gains extendsExpression/generatedClassImports/externalImports; with extendsExpression set it renders from a new DTO_CLASS_TEMPLATE with no body of its own.
  • The base-class import goes through FileComponent.TEMP_PREFIX like relation imports, so it resolves to the real snake_case path once every file's location is known. Unlike a relation field it must be a value import — the name appears in an extends clause, which a type-only import can't serve.
  • Omitted keys are read off the generated base class's fields, not off the model, so separateRelationFields (whose base has already dropped relations) and /// @skip can't produce an OmitType key that isn't keyof the class. There's a test for exactly that.
  • GraphQL helper imports are skipped for DTO files — with no fields they'd be guaranteed-unused.
  • Composite types (MongoDB type blocks) get no DTOs: embedded values, not entities with endpoints.

Compatibility

Off by default. Fixture snapshots are unchanged, so existing output is byte-identical. 228 tests pass (13 new). README options/FAQ/comparison table and the "Future Plan" list are updated.

🤖 Generated with Claude Code

kimjbstar and others added 2 commits August 12, 2026 16:10
…(makeDtoFiles)

The README's "Future Plan" has listed per-model Create/Update DTOs since the
beginning, deferred on the grounds that deciding what a create payload looks like
belongs to the developer. That reasoning has been overtaken by this generator's
own behaviour: useValidation decides which validators are right, useSerialization
and /// @exclude decide what leaves a response, validateNestedRelations assumes a
nested payload shape, and @default(...) already becomes a Swagger `example`. DTOs
were the one place the stated philosophy was still being applied.

Applying it properly is what produces this feature rather than blocking it. The
rule this repo actually holds to is "reflect what the schema states, never guess
from a field's name" -- the #34/#56/#76 Date misdetection being the cautionary
tale -- and which fields a client can't supply on create is stated outright in the
schema:

  - a *function-based* @default(...) (autoincrement(), uuid(), cuid(), now(),
    auto(), dbgenerated(...), sequence()): the database or Prisma produces it.
    DMMF models these as an { name, args } object, which is exactly what
    distinguishes them from a literal @default(0) -- and literal defaults are
    kept, since "there's a fallback" isn't "you may not set it".
  - @updatedat: Prisma maintains it on every write.
  - relation fields: another entity, not a value. The relation's foreign-key
    scalar (authorId) stays, because that is what a REST client actually posts.

An @id *without* a default stays too: `id String @id` has to come from the caller,
and omitting it just because it's an @id would make the model uncreatable.

The classes are compositions, not copies:

  export class CreatePost extends OmitType(Post, ['id', 'createdAt', 'updatedAt', 'author'] as const) {}
  export class UpdatePost extends PartialType(CreatePost) {}

so a field's type, Swagger metadata and validators are declared in exactly one
place. This is deliberately a different shape from prisma-generator-nestjs-dto's
fully expanded per-DTO classes, and it's the same composition the README FAQ has
always told people to write by hand. Mapped types come from @nestjs/swagger when
useSwagger is on and @nestjs/mapped-types otherwise.

Verified end to end rather than only as strings: the generated output (models,
DTOs, enums, nullable fields, useNonNullableAssertions) compiles under
`tsc --strict` against a real @nestjs/swagger install with zero errors, and at
runtime OmitType carries the validators through (CreatePost rejects a numeric
title, an out-of-range status enum and a non-string authorId) while PartialType
makes them optional without dropping them (UpdatePost accepts {} and still
rejects {title: 123}).

Implementation notes:

  - ClassComponent gains extendsExpression/generatedClassImports/externalImports;
    when extendsExpression is set the class renders from DTO_CLASS_TEMPLATE with no
    body of its own.
  - The base-class import goes through FileComponent.TEMP_PREFIX like relation
    imports do, so it resolves to the real snake_case path once every file's
    location is known. Unlike a relation field it must be a *value* import: the
    name appears in an `extends` clause, which a type-only import can't serve.
  - Omitted keys are read off the generated base class's fields, not off the
    model, so `separateRelationFields` (whose base has already dropped relations)
    and /// @Skip can't produce an OmitType key that isn't `keyof` the class.
  - GraphQL helper imports are skipped for DTO files -- with no fields, they would
    be guaranteed-unused.
  - Composite types get no DTOs: they're embedded values, not entities with their
    own endpoints.

Off by default, so existing output is byte-identical (fixture snapshots unchanged).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s in a table

Four things a reader actually trips over in a 600-line README:

- No way to navigate it. Added a one-line contents index plus a short callout for
  the single most-reported gotcha (#59): dryRun defaults to true, so a first run
  prints instead of writing.
- The install step listed `npm install` and `yarn add` as two lines of one shell
  block, which reads like you run both. Split into separate blocks, and both now
  install as a *dev* dependency -- this runs inside `prisma generate` and the
  classes it writes never import it, so it has no business in production
  dependencies.
- 15 options were only documented as a flat bullet list, so there was nowhere to
  see "what can I set, and what's the default" at a glance. Added a summary table
  above the existing details, and a note that Prisma passes every generator config
  value as a string (`dryRun = "false"`, not `dryRun = false`) -- a real
  first-run failure mode.
- The useValidation note explained the missing relation validator as "this library
  hands DTO composition to the caller", which stopped being the whole story once
  validateNestedRelations and makeDtoFiles existed. Reworded to the actual reason
  (validating a nested payload means assuming a shape the schema doesn't state)
  and pointed at the option that opts into it.

The option table was diffed against PrismaClassGeneratorOptions programmatically:
14/14 match, nothing missing or invented.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kimjbstar
kimjbstar merged commit 5696c4c into main Aug 12, 2026
12 checks passed
@kimjbstar
kimjbstar deleted the feat/dto-mapped-types branch August 12, 2026 07:36
@github-actions github-actions Bot mentioned this pull request Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant