Prisma enum vs 도메인 타입 캐스팅 함정 — TypeScript 타입 불일치 해결기
📚 NestJS 실전 트러블슈팅 시리즈 (12편)
Prisma가 생성한 enum과 도메인 타입이 TypeScript에서 호환되지 않아 빌드 에러가 발생합니다. 명시적 캐스팅 패턴과 아키텍처별 예방 전략을 정리합니다.
💡 Tip. 바쁜 현대인들을 위한 본문 요약
- Prisma enum과 도메인 타입은 값이 같아도 TypeScript에서 별개 타입으로 취급된다
- 핵심 에러:
Type 'TaskState' is not assignable to type 'PrismaTaskState'- 즉각 해결:
as PrismaEnum명시적 캐스팅으로 단기간 해소- 장기 전략: 소규모는 Prisma enum 직접 사용, 대규모는 매핑 유틸리티 중앙화
- JSON 필드는
as unknown as DomainType이중 캐스팅이 추가로 필요하다
값이 완전히 동일한데 TypeScript가 “호환 불가”라며 빌드를 거부하는 경험, Prisma를 쓰다 보면 한 번쯤 겪게 된다.
처음엔 내가 뭔가 잘못 정의했나 싶어서 스키마를 세 번은 다시 봤다.
🔍 증상: 빌드는 되는데 타입 에러
코드는 아무 문제 없어 보였다.
Prisma 스키마에 TaskState enum을 정의했고, 도메인 레이어에서도 같은 값을 가진 타입을 따로 만들었다.
값은 'ACTIVE', 'COMPLETED', 'ARCHIVED'로 완전히 동일한데, TypeScript 컴파일러가 이 에러를 뱉어냈다.
Type 'TaskState' is not assignable to type 'PrismaTaskState'.
IDE에서는 멀쩡하게 자동완성도 됐다.
근데 tsc --noEmit 돌리면 바로 터졌다 💀
src/task/task.repository.ts:42:14 - error TS2322:
Type 'TaskState' is not assignable to type '$Enums.TaskState'.
Type '"ACTIVE"' is not assignable to type 'TaskState'.
42 state: domainState,
~~~~~~~~~~~~~~~~~
에러 메시지에서 $Enums.TaskState라는 내부 네임스페이스가 보이는 게 힌트였다.
📌 핵심: 에러 메시지의
$Enums.접두사가 핵심 단서다. Prisma는 생성된 enum을 자체 네임스페이스$Enums에 격리한다.
🔎 원인: Prisma가 생성한 enum은 별도 타입 시스템

처음엔 단순 임포트 경로 문제라고 생각했다.
@prisma/client에서 임포트하지 않고 직접 참조하고 있나 싶어서 import 문을 다 뒤졌는데, 그게 아니었다.
TypeScript 구조적 타이핑의 예외 — enum
TypeScript는 기본적으로 구조적 타이핑(structural typing)을 따른다. 모양이 같으면 같은 타입으로 인정한다는 의미다.
근데 enum은 다르다.
TypeScript의 enum은 명목적 타이핑(nominal typing)처럼 동작한다. 출처가 다른 두 enum은 값이 완전히 같아도 서로 다른 타입이다.
// @prisma/client가 자동 생성한 enum
// (node_modules/.prisma/client/index.d.ts 안에 생성됨)
export enum TaskState {
ACTIVE = 'ACTIVE',
COMPLETED = 'COMPLETED',
ARCHIVED = 'ARCHIVED',
}
// 우리가 도메인 레이어에서 직접 정의한 타입
export type TaskState = 'ACTIVE' | 'COMPLETED' | 'ARCHIVED';
// 또는 이렇게 enum으로 정의해도 마찬가지
export enum TaskState {
ACTIVE = 'ACTIVE',
COMPLETED = 'COMPLETED',
ARCHIVED = 'ARCHIVED',
}
이 두 타입은 TypeScript 컴파일러 입장에서 전혀 다른 타입이다.
⚠️ 주의: TypeScript의 string enum은 구조가 같아도 선언 위치가 다르면 호환되지 않는다. 이건 TypeScript 설계 의도이지 버그가 아니다.
왜 Prisma는 자체 enum을 생성할까
npx prisma generate를 실행하면 Prisma는 node_modules/.prisma/client/ 경로에 TypeScript 타입 정의를 통째로 생성한다.
이 생성 파일은 프로젝트 코드와 완전히 격리된 공간에 위치한다.
우리가 src/ 밑에서 만든 타입은 Prisma 생성 파일이 전혀 모르는 타입이고, 반대도 마찬가지다.
결국 문제의 본질은 이렇다.
- Prisma enum:
node_modules/.prisma/client네임스페이스 소속 - 도메인 enum:
src/네임스페이스 소속 - 두 타입은 값이 같아도 출처가 다른 별개 타입
🔍 분석: 정확히는 Prisma가 내부적으로
$Enums네임스페이스를 사용한다.@prisma/client에서 re-export되지만, 타입 계보는$Enums.TaskState에서 출발한다.
✅ 해결: 명시적 as 캐스팅 패턴
❌ 문제의 코드
// task.repository.ts
import { TaskState } from '../domain/task.types'; // 도메인 타입
import { PrismaService } from '../prisma/prisma.service';
async updateState(taskId: number, domainState: TaskState) {
await this.prisma.task.update({
where: { id: taskId },
data: {
state: domainState,
// ❌ TS2322: 도메인 TaskState → Prisma TaskState 직접 대입 불가
},
});
}
Prisma가 기대하는 타입은 $Enums.TaskState인데, 넘어오는 타입은 도메인 TaskState다.
이름도 같고 값도 같지만, TypeScript는 이걸 다른 타입으로 판단해서 에러를 뱉는다.
✅ 해결한 코드
// task.repository.ts
import { TaskState } from '../domain/task.types';
import { TaskState as PrismaTaskState } from '@prisma/client'; // Prisma enum 명시적 import
async updateState(taskId: number, domainState: TaskState) {
await this.prisma.task.update({
where: { id: taskId },
data: {
// ✅ 명시적 as 캐스팅 — 값은 동일하므로 런타임 안전
state: domainState as unknown as PrismaTaskState,
},
});
}
반대 방향도 동일하게 처리한다.
// Prisma 조회 결과를 도메인 타입으로 읽어올 때
async findById(taskId: number): Promise<Task> {
const raw = await this.prisma.task.findUnique({ where: { id: taskId } });
return {
...raw,
// ✅ Prisma → 도메인 방향 캐스팅
state: raw.state as unknown as TaskState,
};
}
⚠️ JSON 필드 이중 캐스팅 주의
일반 enum은 as PrismaEnum 한 번으로 충분하다.
근데 Prisma의 Json? 필드는 다르다.
Prisma.JsonValue 타입은 string | number | boolean | null | JsonArray | JsonObject의 복합 타입이라, 도메인 인터페이스로 바로 캐스팅하면 에러가 난다.
// ❌ 이렇게 하면 안 됨 — Prisma.JsonValue → DomainInterface 직접 캐스팅 불가
const metadata = raw.metadata as DomainMetadata;
// ✅ unknown을 경유하는 이중 캐스팅 필요
const metadata = raw.metadata as unknown as DomainMetadata;
// unknown으로 한 번 타입을 지운 뒤, 원하는 타입으로 재지정하는 패턴
📌 핵심:
as unknown as T패턴은 TypeScript 타입 시스템을 우회하는 강제 캐스팅이다. 런타임에서 실제 값 구조가T와 일치한다는 확신이 있을 때만 사용할 것.
패턴별 정리
| 상황 | 패턴 | 예시 |
|---|---|---|
| 도메인 → Prisma 쓰기 (enum) | as PrismaEnum | state as PrismaTaskState |
| Prisma → 도메인 읽기 (enum) | as DomainType | raw.state as TaskState |
| Prisma JSON 필드 읽기 | as unknown as T | raw.metadata as unknown as DomainMeta |
| 도메인 → Prisma JSON 쓰기 | as unknown as Prisma.JsonValue | metadata as unknown as Prisma.JsonValue |
🔬 검증: 수정 전/후 빌드 결과
❌ 수정 전
$ tsc --noEmit
src/task/task.repository.ts:42:14 - error TS2322:
Type 'TaskState' is not assignable to type '$Enums.TaskState'.
Type '"ACTIVE"' is not assignable to type 'TaskState'.
Found 3 errors in 2 files.
✅ 수정 후
$ tsc --noEmit
# 출력 없음 — 에러 제로
$ pnpm build
✓ Compiled successfully in 4.2s
런타임에서도 정상 동작한다. as 캐스팅은 컴파일 타임에만 존재하는 타입 힌트이고, 런타임에는 완전히 사라진다.
실제 값('ACTIVE' 같은 문자열)은 동일하므로 DB 저장/조회 모두 의도한 대로 작동했다.
💡 팁: 런타임 안전성이 걱정된다면 캐스팅 전에
Object.values(PrismaTaskState).includes(domainState)같은 가드를 추가하면 된다. 특히 외부 입력값을 그대로 넘기는 경우 권장한다.
🛡️ 예방: Prisma enum 직접 사용 vs 래핑 전략

이 문제는 근본적으로 “도메인 타입과 Prisma 타입을 분리할 것인가”의 아키텍처 선택에서 비롯된다.
직접 써보니 프로젝트 규모에 따라 선택지가 달라지더라.
전략 1: Prisma enum 직접 사용 (소규모 프로젝트 추천)
도메인 레이어에서 별도 타입을 정의하지 않고, @prisma/client의 enum을 그대로 가져다 쓴다.
// task.service.ts
import { TaskState } from '@prisma/client'; // 도메인 타입 없이 Prisma enum 직접 사용
@Injectable()
export class TaskService {
async activate(taskId: number): Promise<void> {
await this.taskRepository.updateState(taskId, TaskState.ACTIVE);
// ✅ 캐스팅 불필요 — Prisma 타입을 그대로 전달
}
}
장점: 캐스팅 코드 없음, 타입 안전성 100%
단점: 서비스 레이어가 @prisma/client에 직접 의존, ORM 교체 시 전파 범위 큼
제 경우에는 팀 내부 프로젝트(약 20개 모델)는 이 방식을 택했다. 어차피 Prisma를 바꿀 일이 없었고, 불필요한 추상화가 오히려 코드를 복잡하게 만들었기 때문이다.
전략 2: 매핑 유틸리티 함수 (대규모 프로젝트 추천)
도메인 타입은 유지하되, Prisma 타입과의 변환 로직을 한 곳에 모아둔다.
// shared/mappers/task-state.mapper.ts
import { TaskState as PrismaTaskState } from '@prisma/client';
import { TaskState } from '../../domain/task/task.types';
// 도메인 → Prisma 변환
export function toPrismaTaskState(state: TaskState): PrismaTaskState {
// ✅ 변환 로직을 한 곳에 격리 — ORM 교체 시 이 파일만 수정
return state as unknown as PrismaTaskState;
}
// Prisma → 도메인 변환
export function fromPrismaTaskState(state: PrismaTaskState): TaskState {
return state as unknown as TaskState;
}
장점: 변환 로직 중앙화, ORM 교체 시 매퍼 파일만 수정 단점: 파일 수 증가, 초기 보일러플레이트 비용 발생
전략 선택 기준
| 상황 | 추천 전략 |
|---|---|
| 팀 5명 이하, 단일 DB | Prisma enum 직접 사용 |
| 도메인 레이어를 엄격히 분리하는 DDD 구조 | 매핑 유틸리티 |
| MSA로 확장 가능성 있음 | 매핑 유틸리티 |
| ORM 교체 가능성 거의 없음 | Prisma enum 직접 사용 |
⚠️ 주의: “언젠가 ORM 바꿀 수도 있으니까”라는 이유로 전략 2를 선택하면 오버엔지니어링이 된다. 실제 교체 가능성과 팀 규모를 먼저 현실적으로 평가하는 게 맞다.
📋 정리
| 항목 | 내용 |
|---|---|
| 증상 | Type 'X' is not assignable to type '$Enums.X' 빌드 에러 |
| 원인 | Prisma 생성 enum($Enums)과 도메인 타입은 별개 타입 계보 |
| 즉각 해결 | as PrismaEnum 명시적 캐스팅, JSON 필드는 as unknown as T |
| 소규모 예방 | @prisma/client enum을 도메인에서 직접 사용 |
| 대규모 예방 | 매핑 유틸리티 함수로 변환 로직 중앙화 |
값이 같아도 TypeScript에서 출처가 다른 enum은 호환되지 않는다.
as 캐스팅은 임시방편이 아니라, 이 문제에 대한 TypeScript 레벨의 정당한 해결 패턴이다 ✨
같은 시리즈의 다른 글:
📚 NestJS 실전 트러블슈팅 시리즈 (12편)
- 1. NestJS + Prisma에서 N+1 쿼리 문제 해결하기
- 2. NestJS CORS 삽질 총정리 — PATCH만 안 되는 이유
- 3. Prisma 마이그레이션 실수 방지 — 컬럼 누락 해결기
- 4. NestJS DTO 클래스 필수인 이유 — interface로 만들면 터지는 두 가지
- 5. NestJS FK 제약 위반 디버깅 — Level ID 검증으로 500 에러 잡기
- 6. Prisma enum vs 도메인 타입 캐스팅 함정 — TypeScript 타입 불일치 해결기
- 7. Seed 데이터 FK 삭제 순서 삽질 — Prisma deleteMany가 터지는 이유
- 8. NestJS DI 에러 디버깅 — Nest can't resolve dependencies 3가지 원인과 서버 기동 테스트
- 9. Docker 빌드에서 pnpm 모노레포 삽질 — 데코레이터 에러 3132개의 정체
- 10. NestJS 재귀 호출 무한루프 — API 504 타임아웃의 숨겨진 원인 찾기
- 11. Soft Delete 필터가 빠진 곳 찾기 — 삭제한 데이터가 되살아나는 미스터리
- 12. prisma generate 누락 — 빌드는 되는데 런타임 에러가 나는 이유