이번 편의 결과물: lib/types/의 유틸 타입(UnwrapPromise, ElementOf, FormErrors, DeepPartial, DeepReadonly, pipe, 브랜디드 ID)마다 test-d.ts 타입 테스트가 작성되어 npm run test:types(vitest --typecheck)로 통과합니다. · 다루는 개념: expectTypeOf/assertType, test-d.ts 파일 작성, typecheck.enabled 모드
03~11편에서 lib/types/에 조건부 타입·매핑된 타입·재귀 타입·브랜디드 타입·가변 튜플 유틸을 쌓았습니다. 지금까지는 이 타입들을 실제 코드(거래 목록, 폼, API 클라이언트)에 적용해 보는 방식으로만 확인했습니다. 문제는 유틸 타입 자체를 고치다가 의도와 다르게 동작이 바뀌어도, 적용한 곳에서 우연히 타입 오류가 나지 않으면 알아챌 방법이 없다는 점입니다. 이 편에서는 유틸 타입 각각에 대해 “이 입력을 넣으면 이 타입이 나와야 한다”를 코드로 고정하는 타입 테스트를 작성합니다.
이 편에서 만드는 파일
expense-tracker/
├── package.json ~ (expect-type devDependency, test:types 스크립트)
├── vitest.config.ts ~ (typecheck 옵션 추가)
└── src/lib/types/
├── utility.test-d.ts + (UnwrapPromise·ElementOf·FormErrors·JsonValue·DeepPartial·DeepReadonly)
├── brand.test-d.ts + (TransactionId·CategoryId)
└── pipe.test-d.ts + (가변 튜플 pipe)개념 정리
런타임 테스트와 타입 테스트의 차이
17편까지 써온 *.test.ts는 함수를 실제로 호출해 반환값을 expect(...).toBe(...)로 검사합니다. 반면 *.test-d.ts 파일은 실행되지 않습니다. Vitest는 이 파일을 tsc(또는 vue-tsc)에 넘겨 타입 검사만 하고, 검사 결과 통과 여부로 성공·실패를 가릅니다. 파일 안에서 test.each처럼 런타임에 이름이 결정되는 문법을 써도 실제로 여러 번 돌지 않는 이유가 여기 있습니다.
expect-type의 핵심 API
expectTypeOf(값 또는 <타입>())로 시작해 비교 메서드를 이어 붙입니다.
| 메서드 | 역할 |
|---|---|
.toEqualTypeOf<T>() | 정확히 같은 타입인지 확인. 프로퍼티가 하나라도 남거나 모자라면 실패 |
.toExtend<T>() | 이 타입이 T에 대입 가능한지(“is-a” 관계)만 확인 |
.not | 뒤에 오는 검사를 반전 |
과거 버전의 .toMatchTypeOf는 폐기(deprecated)되어, 부분 일치는 .toMatchObjectType, 대입 가능성은 .toExtend로 나눠졌습니다. 값 대신 제네릭 타입 인자만으로도 검사할 수 있습니다.
expectTypeOf<string>().toExtend<string | number>()assertType<T>(값)은 expectTypeOf보다 짧게, “이 값이 T에 대입 가능한가”만 한 줄로 확인할 때 씁니다. 기대와 다른 타입이 되어야 하는 자리에는 바로 위 줄에 // @ts-expect-error를 붙여, 그 줄이 실제로 타입 오류를 내는지까지 검증합니다.
typecheck.enabled 모드
Vitest 설정의 test.typecheck.include는 기본값이 **/*.{test,spec}-d.ts라서, 파일 이름을 이 규칙에 맞추기만 하면 별도 설정 없이 인식됩니다. 다만 typecheck.enabled를 설정 파일에서 true로 고정하면 npm run test(일반 단위 테스트)를 돌릴 때도 매번 타입 검사가 함께 실행돼 느려집니다. 이 프로젝트는 명령줄 플래그 --typecheck로 필요할 때만 켜는 방식을 씁니다.
실습
1. expect-type 설치와 스크립트 추가
npm install -D expect-type// package.json (발췌)
{
"scripts": {
"test": "vitest run",
"test:types": "vitest --typecheck --run",
"coverage": "vitest run --coverage"
},
"devDependencies": {
"expect-type": "^1.4.0"
}
}2. vitest.config.ts에 typecheck 옵션 명시
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'jsdom',
coverage: {
provider: 'v8',
include: ['src/services/**'],
},
typecheck: {
enabled: false,
include: ['src/**/*.test-d.ts'],
},
},
})enabled: false로 두어 평소 npm run test는 빨리 끝나게 하고, test:types 스크립트가 넘기는 --typecheck 플래그가 이 회차에서만 검사를 켭니다.
3. utility.test-d.ts 작성
// src/lib/types/utility.test-d.ts
import { expectTypeOf } from 'expect-type'
import type { DeepPartial, DeepReadonly, ElementOf, FormErrors, JsonValue, UnwrapPromise } from './utility.ts'
// UnwrapPromise: 중첩된 Promise를 재귀적으로 풀어낸다
expectTypeOf<UnwrapPromise<Promise<string>>>().toEqualTypeOf<string>()
expectTypeOf<UnwrapPromise<Promise<Promise<number>>>>().toEqualTypeOf<number>()
expectTypeOf<UnwrapPromise<boolean>>().toEqualTypeOf<boolean>()
// ElementOf: 배열 타입에서 요소 타입을 뽑아낸다
expectTypeOf<ElementOf<string[]>>().toEqualTypeOf<string>()
expectTypeOf<ElementOf<readonly number[]>>().toEqualTypeOf<number>()
// FormErrors: 각 필드 키에 Error 접미사를 붙인 선택적 문자열 맵으로 리매핑한다
type DraftInput = { amount: number; memo: string }
expectTypeOf<FormErrors<DraftInput>>().toEqualTypeOf<{ amountError?: string; memoError?: string }>()
// JsonValue: 중첩된 배열·객체까지 재귀적으로 허용한다
expectTypeOf<{ items: [1, 2, { nested: true }] }>().toExtend<JsonValue>()
// DeepPartial: 중첩 객체의 모든 단계가 선택적이 된다
type BudgetLimits = { monthly: { food: number; transport: number } }
expectTypeOf<DeepPartial<BudgetLimits>>().toEqualTypeOf<{ monthly?: { food?: number; transport?: number } }>()
// DeepReadonly: 중첩 객체의 모든 단계가 readonly가 된다
expectTypeOf<DeepReadonly<BudgetLimits>>().toEqualTypeOf<{
readonly monthly: { readonly food: number; readonly transport: number }
}>()4. brand.test-d.ts 작성
// src/lib/types/brand.test-d.ts
import { expectTypeOf } from 'expect-type'
import { toCategoryId, toTransactionId, type CategoryId, type TransactionId } from './brand.ts'
// 브랜디드 타입은 원래 원시 타입에는 대입 가능하다(구조적으로 string을 포함)
expectTypeOf<TransactionId>().toExtend<string>()
// 하지만 일반 string은 브랜디드 타입에 대입할 수 없다
expectTypeOf<string>().not.toExtend<TransactionId>()
// 서로 다른 브랜드끼리도 섞이지 않는다
expectTypeOf<CategoryId>().not.toExtend<TransactionId>()
// 헬퍼 함수의 반환 타입이 각각의 브랜드로 고정된다
expectTypeOf(toTransactionId('t-1')).toEqualTypeOf<TransactionId>()
expectTypeOf(toCategoryId('food')).toEqualTypeOf<CategoryId>()5. pipe.test-d.ts 작성
// src/lib/types/pipe.test-d.ts
import { assertType, expectTypeOf } from 'expect-type'
import { pipe } from './pipe.ts'
// 함수 하나만 거치면 그 함수의 반환 타입이 된다
const doubled = pipe(2, (n) => n * 2)
expectTypeOf(doubled).toEqualTypeOf<number>()
// 여러 함수를 거치면 각 단계의 반환 타입이 다음 단계 입력으로 이어진다
const label = pipe(
10,
(n) => n.toString(),
(s) => `금액: ${s}`,
(s) => s.length,
)
assertType<number>(label)6. 실행
npm run test:types✓ src/lib/types/utility.test-d.ts (6)
✓ src/lib/types/brand.test-d.ts (5)
✓ src/lib/types/pipe.test-d.ts (2)
Type Test Files 3 passed (3)기존 런타임 테스트도 함께 통과하는지 확인합니다.
npm run test직접 해보기
utility.test-d.ts의FormErrors<DraftInput>검사를{ amountError?: number; memoError?: string }(타입이 하나 틀림)로 바꿔 실행하고,npm run test:types가 실패로 보고하는 오류 메시지를 읽어 봅니다.pipe.test-d.ts에 문자열을 받아 숫자를 반환하는 함수와 숫자를 받아 문자열을 반환하는 함수를 번갈아 3번 이어 붙인pipe호출을 추가해, 최종 반환 타입이 예상대로 이어지는지 확인해 보세요.
정답 보기
amountError를 number로 바꾸면 toEqualTypeOf가 실제 타입(string)과 기대 타입(number)이 다르다는 컴파일 오류를 냅니다. 오류 메시지는 어떤 프로퍼티가 어떤 타입이어야 하는지 함께 보여줍니다.
const chained = pipe(
5,
(n) => `${n}개`,
(s) => s.length,
(n) => `총 ${n}자`,
)
expectTypeOf(chained).toEqualTypeOf<string>()세 함수를 거치는 동안 number → string → number → string으로 타입이 이어지고, pipe의 가변 튜플 시그니처가 각 단계의 입력·출력을 순서대로 맞춰 최종 타입을 string으로 추론합니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
test-d.ts 파일을 실행해도 콘솔에 아무 로그도 안 보임 | 애초에 이 파일은 실행되지 않고 정적 검사만 됨 | console.log로 확인하려 하지 말고 npm run test:types의 통과·실패 여부로 판단한다 |
npm run test를 돌릴 때마다 느려짐 | typecheck.enabled: true를 설정 파일에 고정함 | enabled: false로 두고 필요할 때 --typecheck 플래그로만 켠다 |
toEqualTypeOf가 프로퍼티 순서만 다른데도 통과함 | 객체 타입은 프로퍼티 순서를 구분하지 않음(정상 동작) | 순서가 아니라 프로퍼티 구성이 같은지가 기준이라는 점을 이해한다 |
toMatchTypeOf를 쓰다 타입 오류 경고가 뜸 | 폐기된 메서드를 그대로 사용 | 대입 가능성 확인은 toExtend, 부분 일치는 toMatchObjectType으로 바꾼다 |