이번 편의 결과물: lib/types/index.ts 배럴로 모든 유틸 타입이 한 곳에서 export되고, 전체 npm run typecheck가 경고 없이 통과합니다. · 다루는 개념: 배럴(barrel) export, JSDoc 주석, 대체된 코드 제거
03~16편을 거치며 lib/types/에 파일 세 개(utility.ts, brand.ts, pipe.ts)와 타입 테스트 세 개가 쌓였습니다. 다른 파일에서 이 유틸을 쓰려면 지금까지는 ./types/utility.ts, ./types/brand.ts처럼 각각 다른 경로를 기억해야 했습니다. 또한 12편에서 만든 수동 타입 가드(isTransaction, assertIsTransaction)는 13편에서 zod 스키마로 완전히 대체된 뒤에도 파일로 남아 있습니다. 이 편에서는 라이브러리를 마무리 상태로 정리합니다.
이 편에서 만드는 파일
expense-tracker/src/
├── lib/
│ ├── types/
│ │ ├── index.ts + (배럴, 모든 유틸 타입 재-export)
│ │ ├── utility.ts ~ (JSDoc 주석 추가)
│ │ ├── brand.ts ~ (JSDoc 주석 추가)
│ │ ├── pipe.ts ~ (JSDoc 주석 추가)
│ │ └── guards.ts - (삭제. 12편 수동 가드, 13편 zod로 완전히 대체됨)
│ └── api/
│ └── client.ts ~ (개별 경로 import를 배럴 import로 교체)
└── schemas/
└── transaction.ts ~ (FormErrors import를 배럴 import로 교체)개념 정리
배럴(barrel) export
폴더 하나에 파일이 여러 개일 때, index.ts에서 그 파일들을 한 번에 다시 내보내면 사용하는 쪽은 폴더 경로 하나만 기억하면 됩니다.
export * from './utility.ts'
export * from './brand.ts'
export * from './pipe.ts'export *는 각 파일이 내보내는 이름을 그대로 이어받습니다. 파일 사이에 이름이 겹치면 컴파일 오류가 나므로, 배럴을 만들기 전에 각 파일의 export 이름이 서로 겹치지 않는지 먼저 확인해야 합니다. lib/types/의 세 파일은 UnwrapPromise, ElementOf, FormErrors, JsonValue, DeepPartial, DeepReadonly, Brand, TransactionId, CategoryId, toTransactionId, toCategoryId, pipe로 이름이 서로 겹치지 않습니다.
배럴을 만들 때 주의할 점
같은 폴더 안 파일끼리 서로를 배럴(./index.ts)을 거쳐 참조하면 순환 참조가 생길 수 있습니다. utility.ts가 brand.ts의 TransactionId를 쓰고 싶다면 ./index.ts가 아니라 ./brand.ts를 직접 import합니다. 배럴은 폴더 바깥에서 안으로 들어올 때만 쓰는 창구로 제한합니다.
JSDoc 주석
함수·타입 선언 바로 위에 /** ... */ 형태로 설명을 달면, 에디터가 그 함수를 쓰는 곳에서 마우스를 올렸을 때 설명을 바로 보여줍니다. @param, @example처럼 정해진 태그를 쓰면 매개변수별 설명과 사용 예시도 함께 표시됩니다.
/**
* Promise를 재귀적으로 벗겨 내부 값의 타입을 반환합니다.
* @example UnwrapPromise<Promise<Promise<string>>> // string
*/
export type UnwrapPromise<T> = T extends Promise<infer U> ? UnwrapPromise<U> : T대체된 코드를 지우기 전에 확인할 것
파일을 지우기 전에 그 파일의 export를 실제로 쓰는 곳이 남아 있는지 검색부터 합니다. 검색 결과가 하나도 없어야 안전하게 지울 수 있습니다.
실습
1. guards.ts 사용처 확인
grep -rn "guards.ts\|isTransaction\|assertIsTransaction" src --include="*.ts"src/lib/types/guards.ts:export function isTransaction(...
src/lib/types/guards.ts:export function assertIsTransaction(...lib/api/client.ts, schemas/transaction.ts 등 다른 파일 어디에서도 isTransaction, assertIsTransaction을 더 이상 import하지 않습니다. 13편에서 API 클라이언트와 폼 검증 모두 zod 파싱으로 옮겨간 뒤 유일하게 이 함수들을 부르던 자리가 사라졌기 때문입니다. 선언한 파일 자신을 빼면 참조가 없으므로 안전하게 지웁니다.
rm src/lib/types/guards.ts2. 각 유틸 파일에 JSDoc 추가
// src/lib/types/utility.ts
/**
* Promise를 재귀적으로 벗겨 내부 값의 타입을 반환합니다.
* @example UnwrapPromise<Promise<Promise<string>>> // string
*/
export type UnwrapPromise<T> = T extends Promise<infer U> ? UnwrapPromise<U> : T
/**
* 배열 또는 읽기 전용 배열 타입에서 요소 타입을 뽑아냅니다.
* @example ElementOf<Transaction[]> // Transaction
*/
export type ElementOf<T> = T extends readonly (infer U)[] ? U : never
/**
* 객체의 각 키에 Error 접미사를 붙인, 필드별 에러 메시지 맵을 만듭니다.
* @example FormErrors<{ amount: number }> // { amountError?: string }
*/
export type FormErrors<T> = {
[K in keyof T as `${string & K}Error`]?: string
}
/** JSON으로 직렬화 가능한 값의 재귀적 유니온입니다. */
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }
/**
* 중첩된 객체의 모든 단계를 선택적 프로퍼티로 만듭니다.
* @example DeepPartial<{ a: { b: number } }> // { a?: { b?: number } }
*/
export type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T
/**
* 중첩된 객체의 모든 단계를 readonly로 만듭니다.
* @example DeepReadonly<{ a: { b: number } }> // { readonly a: { readonly b: number } }
*/
export type DeepReadonly<T> = T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } : T// src/lib/types/brand.ts
declare const brandSymbol: unique symbol
/** 원시 타입 T에 문자열 태그 B를 붙여 구조가 같아도 서로 섞이지 않게 만듭니다. */
export type Brand<T, B extends string> = T & { readonly [brandSymbol]: B }
/** 거래 하나를 식별하는 브랜디드 문자열 ID입니다. */
export type TransactionId = Brand<string, 'TransactionId'>
/** 카테고리 하나를 식별하는 브랜디드 문자열 ID입니다. */
export type CategoryId = Brand<string, 'CategoryId'>
/** 일반 문자열을 TransactionId로 표시합니다. 값 자체는 바뀌지 않습니다. */
export function toTransactionId(value: string): TransactionId {
return value as TransactionId
}
/** 일반 문자열을 CategoryId로 표시합니다. 값 자체는 바뀌지 않습니다. */
export function toCategoryId(value: string): CategoryId {
return value as CategoryId
}// src/lib/types/pipe.ts
type UnaryFn<Input, Output> = (value: Input) => Output
type PipeReturn<Input, Fns extends readonly UnaryFn<any, any>[]> = Fns extends [
UnaryFn<Input, infer Output>,
...infer Rest extends readonly UnaryFn<any, any>[],
]
? Rest extends []
? Output
: PipeReturn<Output, Rest>
: Input
/**
* 값을 여러 단항 함수에 순서대로 통과시킵니다. 각 단계의 반환 타입이 다음 단계 입력 타입과 맞아야 합니다.
* @example pipe(2, (n) => n * 2, (n) => `${n}원`) // '4원'
*/
export function pipe<Input, Fns extends readonly UnaryFn<any, any>[]>(value: Input, ...fns: Fns): PipeReturn<Input, Fns> {
return fns.reduce((acc, fn) => fn(acc), value as unknown) as PipeReturn<Input, Fns>
}3. 배럴 파일 작성
// src/lib/types/index.ts
export * from './utility.ts'
export * from './brand.ts'
export * from './pipe.ts'4. 사용하는 곳의 import를 배럴 경로로 교체
// src/lib/api/client.ts (발췌)
import type { ElementOf, UnwrapPromise } from '../types/index.ts'// src/schemas/transaction.ts (발췌)
import type { FormErrors } from '../lib/types/index.ts'5. 실행
npm run typechecktsc --noEmit
(오류 없음)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)utility.test-d.ts 등 16편 타입 테스트 파일은 개별 경로(./utility.ts, ./brand.ts, ./pipe.ts)에서 import하도록 그대로 둡니다. 라이브러리 안에서 자기 자신을 배럴로 다시 참조하면 순환 참조 위험이 생기기 때문입니다.
직접 해보기
lib/types/index.ts에export * as types from './utility.ts'처럼 네임스페이스 export를 추가해 보고, 다른 파일에서types.UnwrapPromise<...>형태로도 쓸 수 있는지 확인해 보세요.guards.ts를 지우기 전에 되돌릴 수 있도록, 삭제 전git status로 변경 사항을 확인하는 습관을 만들어 보세요.
정답 보기
export * as types from './utility.ts'이렇게 추가하면 import { types } from '../types/index.ts' 뒤 types.UnwrapPromise<Promise<string>>처럼 네임스페이스를 거쳐 쓸 수 있습니다. 다만 이 프로젝트는 개별 이름을 바로 꺼내 쓰는 export * 방식으로 통일했으므로, 네임스페이스 export는 실습용으로만 추가했다가 제거해도 됩니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
배럴 추가 후 Circular dependency 경고 | 폴더 안 파일이 ./index.ts를 거쳐 서로를 참조 | 폴더 안에서는 서로 직접 파일 경로로 import하고, 배럴은 폴더 바깥에서만 쓴다 |
export * 추가 후 이름 충돌 오류 | 서로 다른 파일이 같은 이름을 export | 배럴을 만들기 전에 각 파일의 export 이름을 미리 정리한다 |
지운 guards.ts를 다른 파일이 여전히 import해 빌드 실패 | 삭제 전 사용처 검색을 건너뜀 | grep으로 함수 이름을 먼저 검색해 참조가 없는지 확인한 뒤 지운다 |
JSDoc @example에 실제와 다른 결과를 적어둠 | 코드를 고친 뒤 주석은 갱신하지 않음 | 유틸 타입을 고칠 때마다 JSDoc 예시도 함께 검토한다 |