Prisma enum vs 도메인 타입 캐스팅 함정 — TypeScript 타입 불일치 해결기

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가 생성한 enum은 별도 타입 시스템 코드를 한 줄씩 따라가는 중
🔎 원인: 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 PrismaEnumstate as PrismaTaskState
Prisma → 도메인 읽기 (enum)as DomainTyperaw.state as TaskState
Prisma JSON 필드 읽기as unknown as Traw.metadata as unknown as DomainMeta
도메인 → Prisma JSON 쓰기as unknown as Prisma.JsonValuemetadata 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 enum 직접 사용 vs 래핑 전략 예방 체크리스트를 만들고 뿌듯한 표정
🛡️ 예방: 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명 이하, 단일 DBPrisma 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 레벨의 정당한 해결 패턴이다 ✨


같은 시리즈의 다른 글: