TypeScript 공개 타입 설계 export type과 라이브러리 경계

··설명 약 11분·작성: 해비·블로그 소개

TypeScript 모듈에서 어떤 타입을 export하고 어떤 타입을 내부에 숨겨야 하나요?

다른 파일이 알아야 할 타입만 export하고 중간 계산이나 원본 응답의 내부 타입은 숨깁니다. export type은 타입을 공개하고 import type은 사용하는 쪽의 타입 의존성을 표시합니다.

적용 조건·범위: 특히 ESM과 bundler 환경에서 타입 전용 import라는 점이 분명해집니다.

본문 근거: TypeScript 모듈과 타입 전용 import

직접 확인: 두 파일의 공개 타입 경계

TypeScript 공개 타입 설계 export type과 라이브러리 경계 · 독립 실습

오류·수정 코드의 타입 진단과 실행 결과를 비교하세요.

예제 크게 보기 (새 탭)

같은 새 폴더에서 model.ts와 app.ts를 만듭니다. 설치된 TypeScript로 npx tsc --strict --noEmit --module commonjs app.ts를 실행합니다. 이 폴더에는 tsconfig.json을 두지 않습니다.

model.ts 전체

type Task = { id: string; title: string };
export {};

app.ts 전체

import type { Task } from './model';
const task: Task = { id: '1', title: '학습' };
console.log(task.title);

기대 결과: 처음에는 Task가 공개되지 않았다는 오류가 납니다. model.ts의 첫 줄을 export type Task = { id: string; title: string };로 바꿔 다시 검사하면 통과합니다. export {}는 이 파일을 모듈로 표시한 줄이며 타입 공개와는 별개입니다.

작은 변형으로 확인하기

model.ts에서 Task의 title을 제거하면 app.ts의 title 접근이 오류가 됩니다. 내부 타입을 export하지 않아도 공개 Task만으로 사용하는 파일을 검사할 수 있어야 합니다.

공개 타입이란 무엇인가

다른 모듈이 사용하기 위해 알아야 하는 계약입니다.

예를 들어 TaskFlow의 Task 객체가 여러 화면과 API 계층에서 공유된다면 공개 도메인 타입에 가깝습니다.

export type Task = {
  id: string;
  title: string;
  completed: boolean;
};

반면 한 함수 내부 계산에서만 사용하는 중간 타입은 외부에 공개할 이유가 없습니다.

type IndexedTask = {
  index: number;
  task: Task;
};

해당 파일 안에서만 쓰면 export하지 않습니다.

export type

타입만 export한다는 의도를 명확하게 표현할 수 있습니다.

export type {
  Task,
  TaskFilter,
};

다른 파일에서는 다음처럼 가져옵니다.

import type {
  Task,
  TaskFilter,
} from './types';

runtime value import와 타입 import를 구분할 수 있습니다.

import type의 장점

다음 코드를 보겠습니다.

import {
  Task,
} from './types';

Task가 타입으로만 사용된다면 import type이 더 정확한 의도를 표현합니다.

import type {
  Task,
} from './types';

특히 ESM과 bundler 환경에서 타입 전용 import라는 점이 분명해집니다.

내부 타입과 공개 타입 분리

예를 들어 API module 내부에서만 사용하는 응답 원본 shape가 있다고 가정합니다.

type TaskApiItem = {
  task_id: string;
  task_title: string;
  done: boolean;
};

애플리케이션의 public Task 타입은 다음과 다를 수 있습니다.

export type Task = {
  id: string;
  title: string;
  completed: boolean;
};

API 원본 shape를 프로젝트 전체에 노출하지 않고 변환 함수 내부에 숨길 수 있습니다.

function toTask(
  item: TaskApiItem,
): Task {
  return {
    id: item.task_id,
    title: item.task_title,
    completed: item.done,
  };
}

이렇게 하면 서버 응답 형식이 바뀌어도 애플리케이션 전체 타입이 함께 흔들리지 않습니다.

모듈 public API

폴더 안에 여러 파일이 있어도 외부에서는 일부 타입과 함수만 사용하게 만들 수 있습니다.

features/tasks/
├─ api.ts
├─ guards.ts
├─ mapper.ts
├─ model.ts
└─ index.ts

index.ts:

export type {
  Task,
  TaskFilter,
} from './model';


export {
  getTasks,
  createTask,
} from './api';

mapper.ts의 내부 helper는 export하지 않을 수 있습니다.

barrel file의 장점

사용하는 쪽 import가 단순해집니다.

import {
  getTasks,
} from '@/features/tasks';


import type {
  Task,
} from '@/features/tasks';

폴더 내부 파일 구조가 일부 바뀌어도 외부 import 경로를 유지하기 쉽습니다.

barrel file의 단점

무조건 모든 폴더에 index.ts를 만들면 다음 문제가 생길 수 있습니다.

  • 어디서 export되는지 추적 어려움
  • 순환 의존성 가능성
  • 내부 구현까지 무분별하게 re-export
  • IDE 자동 import가 불명확해질 수 있음

작은 프로젝트에서는 직접 파일 import가 더 단순할 수 있습니다.

공개 타입을 너무 넓게 만들지 않기

다음처럼 내부용 타입을 모두 export하면 사용자가 구현 세부사항에 의존할 수 있습니다.

export type TaskEntityMap = ...
export type RawTaskCache = ...
export type InternalTaskDraft = ...

외부 코드가 실제로 알아야 하는 계약만 공개합니다.

props 타입을 export해야 하는가

한 컴포넌트 파일에서만 사용하는 props 타입은 export하지 않아도 됩니다.

type TaskItemProps = {
  task: Task;
};

다른 module에서 해당 props shape를 재사용하거나 라이브러리 component API로 제공해야 할 때 export를 검토합니다.

type file을 하나에 다 모으지 않기

다음 구조는 처음에는 편합니다.

types.ts
```


하지만 모든 도메인의 타입이 한 파일에 모이면 책임이 불분명해집니다.


~~~text
types/
├─ task.ts
├─ user.ts
└─ settings.ts

또는 feature와 가까운 위치에 둘 수 있습니다.

중요한 것은 파일 수가 아니라 타입의 소유 도메인이 명확한가입니다.

public type을 설계할 때 질문

이 타입을 다른 module이 정말 알아야 하는가?
구현 세부사항인가?
서버 원본 shape인가?
도메인 계약인가?
변경되면 어느 범위까지 영향을 주어야 하는가?

공개 범위를 최소화하면 리팩터링이 쉬워집니다.

독립 예제

// model.ts
export type User = {
  id: string;
  name: string;
};


type UserApiItem = {
  user_id: string;
  user_name: string;
};


export function mapUser(
  item: UserApiItem,
): User {
  return {
    id: item.user_id,
    name: item.user_name,
  };
}

더 엄격하게 내부 타입을 숨기고 싶다면 mapper와 raw type을 별도 internal module에 둘 수 있습니다.

export type은 타입을 공개하지만 실행 시 사용할 객체나 함수를 만들지 않습니다. 따라서 타입만 가져오는 소비자는 import type을 사용하고, 실제 변환 함수는 일반 import로 가져오는 역할을 구분합니다.

원리 확인: TypeScript 모듈과 타입 전용 import (2026-10-12). 아래 기대 결과와 기존 실행 기록은 구분해서 읽습니다.

학습 완료 체크리스트

API의 task_title이 내부 Task의 title로 바뀌는 경우를 설명해 보세요. 소비자가 Task 계약만 사용하면 서버 필드명 변경을 mapper 안에서 처리할 수 있고, raw 타입을 공개하면 변경 영향이 밖으로 퍼집니다.


선택 실습: 할 일 앱에 적용하기

TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 JavaScript 코드를 TypeScript로 확장합니다.

다른 module이 실제로 사용해야 하는 타입과 내부 구현 타입을 구분합니다.

선택: 연재 프로젝트와 전체 자료

연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.

TaskFlow 프로젝트 적용

TypeScript 공개 타입 설계 export type과 라이브러리 경계 · 단계별 TaskFlow

타입을 적용한 TaskFlow에서 작업을 추가·완료 전환·삭제하세요. 타입만 바뀐 단계는 이전과 화면이 같습니다.

예제 크게 보기 (새 탭)

시작·완료 파일과 적용 순서에서 이번 변경 파일의 전체 내용과 적용 순서를 확인합니다.

시작본과 이번 변경

TypeScript children 타입: ReactNode와 PropsWithChildren 차이의 완료 체크포인트를 기준으로 이어갑니다. 변경하지 않는 파일은 그 시작 상태를 유지합니다.

src/features/tasks/model.ts에 공개 타입을 두고 mapper.ts는 내부 응답을 Task로 변환합니다. index.ts에는 실제 구현된 API 함수만 re-export하고 기존 import 경로를 바꿉니다. 이동 후 전체 타입 검사와 빌드로 누락된 경로를 확인합니다.

이전 체크포인트

typescript-step-20-children

현재 상태

TaskFlow에는 Task, TaskFilter, CreateTaskInput, UpdateTaskInput, React props 타입 등이 여러 파일에 존재합니다.

이번 목표

다른 module이 실제로 사용해야 하는 타입과 내부 구현 타입을 구분합니다.

권장 구조

src/
├─ features/
│  └─ tasks/
│     ├─ api.ts
│     ├─ guards.ts
│     ├─ mapper.ts
│     ├─ model.ts
│     └─ index.ts
└─ components/
   ├─ TaskItem.tsx
   └─ TaskCard.tsx
전체 파일·코드 보기

model.ts

export type Task = {
  id: string;
  title: string;
  completed: boolean;
};


export type TaskFilter =
  | 'all'
  | 'active'
  | 'completed';


export type CreateTaskInput = {
  title: string;
};


export type UpdateTaskInput =
  Partial<
    Pick<
      Task,
      'title' |
      'completed'
    >
  >;

mapper.ts

외부 API raw shape가 있다고 가정합니다.

import type {
  Task,
} from './model';


type TaskApiItem = {
  task_id: string;
  task_title: string;
  done: boolean;
};


export function toTask(
  item: TaskApiItem,
): Task {
  return {
    id: item.task_id,
    title: item.task_title,
    completed: item.done,
  };
}

TaskApiItem은 mapper.ts 내부에서만 사용하므로 export하지 않습니다.

index.ts

export type {
  Task,
  TaskFilter,
  CreateTaskInput,
  UpdateTaskInput,
} from './model';


export {
  getTasks,
  createTask,
  updateTask,
} from './api';

guards나 mapper 내부 helper까지 모두 re-export하지 않습니다.

컴포넌트 props

TaskItemProps가 TaskItem 내부에서만 필요하다면 export하지 않습니다.

type TaskItemProps = {
  task: Task;
  onDelete:
    (id: string) =>
      void;
};

Before / After

Before
타입 파일 위치와 export 기준이 모호함
내부 raw 타입도 외부에서 접근 가능


After
도메인 public type
→ model.ts / index.ts


내부 변환 타입
→ 해당 module 내부


component 전용 props
→ component 파일 내부

검증

npx tsc --noEmit
npm run build

추가로 IDE 자동 import가 의도한 public path를 사용하는지 확인합니다.

중간부터 시작하는 전체 파일과 적용 순서

이번 글 시작 ZIP · 이번 글 완료 ZIP

시작 ZIP은 직전 프로젝트 단계 typescript-step-20-children의 실제 완료 파일입니다. 프로젝트 순서를 이어가는 기준이며 사이트의 기술 읽기 순서와는 구분합니다.

  1. 시작 ZIP을 새 폴더에 풉니다. 이 폴더가 작업 루트이며 기존 체크포인트 파일을 별도로 찾을 필요가 없습니다.
  2. features/tasks에 model·mapper·guards·api·index 전체 경계를 추가합니다. 기존 types/task는 호환 재내보내기로 유지하고 컴포넌트는 public index에서 타입을 가져옵니다.
  3. 아래 변경 파일을 같은 상대 경로에 추가하거나 전체 교체합니다. 표시하지 않은 파일과 images는 시작본 그대로 유지합니다. package-lock.json은 완료 ZIP에 있는 파일을 함께 적용합니다.
  4. 작업 루트에서 명령을 실행하고 예상 결과를 확인합니다. 완료 ZIP은 직접 적용한 결과를 비교하는 기준입니다.

추가·교체 파일

  • CHANGES.md — 전체 교체
  • README.md — 전체 교체
  • src/types/task.ts — 전체 교체
  • src/components/TaskItem.tsx — 전체 교체
  • src/components/TaskList.tsx — 전체 교체
  • src/App.tsx — 전체 교체
  • src/components/TaskDetail.tsx — 전체 교체
  • src/features/tasks/model.ts — 추가
  • src/features/tasks/mapper.ts — 추가
  • src/features/tasks/guards.ts — 추가
  • src/features/tasks/api.ts — 추가
  • src/features/tasks/index.ts — 추가

실행과 확인

npm ci
npm run dev
npm run typecheck
npm test
npm run build

개발 서버는 실행 상태로 두고 다른 터미널에서 검사 명령을 실행하세요. Node.js 24.18.0에서 검증했습니다. JavaScript는 http://127.0.0.1:4173, TypeScript는 Vite 실행 출력의 주소로 접속합니다.

예상 결과: 기존 CRUD와 React 화면이 유지되며 raw API 필드는 mapper 안에 한정됩니다. 변환 결과의 id/title/completed를 검사합니다.

기존 ZIP 검증 기록: TypeScript strict 검사·긍정/부정 타입 계약·런타임 검사·Vite 빌드 통과; React 폼·버튼·초점 DOM 검사 통과. 시작 ZIP에 이 글의 변경 파일만 적용한 결과와 완료 ZIP의 모든 파일 내용이 일치했습니다. 브라우저 화면 배치·실제 기기·외부 CDN 가용성은 이 검사에 포함하지 않았습니다.

이 절의 전체 파일은 ZIP과 일치합니다. 위쪽 원문의 짧은 예제나 생략 표시가 있는 부분 코드는 개념·변경 위치 설명용입니다. 서버 저장과 인증은 제공하지 않으며 JSON fixture는 로컬 읽기 실습용입니다.

복사할 변경 파일 전체 코드

src/types/task.ts — 전체 파일

export type { Task, TaskFilter, CreateTaskInput, UpdateTaskInput } from '../features/tasks/index.js';
import type { Task } from '../features/tasks/index.js';
export type TaskPreview=Pick<Task,'id'|'title'>;

src/components/TaskItem.tsx — 전체 파일

import type { Task } from '../features/tasks/index.js';
import TaskCard from './TaskCard.js';
type TaskItemProps={task:Task;onToggle:(id:string)=>void;onDelete:(id:string)=>void};
export default function TaskItem({task,onToggle,onDelete}:TaskItemProps){
 return <TaskCard actions={<><button type="button" onClick={()=>onToggle(task.id)}>{task.completed?'완료 취소':'완료'}</button><button type="button" onClick={()=>onDelete(task.id)}>삭제</button></>}><span>{task.title}</span></TaskCard>;
}

src/components/TaskList.tsx — 전체 파일

import TaskItem from './TaskItem.js';
import type {
  Task,
} from '../features/tasks/index.js';


type TaskListProps = {
  tasks: Task[];
  onToggle:
    (id: string) =>
      void;
  onDelete:
    (id: string) =>
      void;
};


export default function TaskList({
  tasks,
  onToggle,
  onDelete,
}: TaskListProps) {
  return (
    <ul>
      {tasks.map((task) => (
        <TaskItem
          key={task.id}
          task={task}
          onToggle={onToggle}
          onDelete={onDelete}
        />
      ))}
    </ul>
  );
}

src/App.tsx — 전체 파일

import TaskDetail from './components/TaskDetail.js';
import { useState } from 'react';
import type { Task } from './features/tasks/index.js';
import { toggleTask, deleteTask, addTask } from './core/tasks.js';
import TaskForm from './components/TaskForm.js';
import TaskList from './components/TaskList.js';
export default function App() {
 const [tasks,setTasks]=useState<Task[]>([{id:'1',title:'TypeScript 공부',completed:false},{id:'2',title:'React props 확인',completed:true}]);
 const onToggle=(id:string)=>setTasks(current=>toggleTask(current,id));
 const onDelete=(id:string)=>setTasks(current=>deleteTask(current,id));
 return <><TaskDetail task={tasks[0] ?? null}/><TaskForm onAdd={title=>setTasks(current=>addTask(current,title))}/><p>전체 {tasks.length} · 완료 {tasks.filter(task=>task.completed).length}</p><TaskList tasks={tasks} onToggle={onToggle} onDelete={onDelete}/></>;
}

src/components/TaskDetail.tsx — 전체 파일

import type { Task } from '../features/tasks/index.js';
type TaskDetailProps = {
  task:
    Task | null;
};


export default function TaskDetail({
  task,
}: TaskDetailProps) {
  if (!task) {
    return (
      <p>
        작업을 선택하세요.
      </p>
    );
  }


  return (
    <section>
      <h2>
        {task.title}
      </h2>
    </section>
  );
}

src/features/tasks/model.ts — 전체 파일

export type Task = {
  id: string;
  title: string;
  completed: boolean;
};


export type TaskFilter =
  | 'all'
  | 'active'
  | 'completed';


export type CreateTaskInput = {
  title: string;
};


export type UpdateTaskInput =
  Partial<
    Pick<
      Task,
      'title' |
      'completed'
    >
  >;

src/features/tasks/mapper.ts — 전체 파일

import type {
  Task,
} from './model.js';


type TaskApiItem = {
  task_id: string;
  task_title: string;
  done: boolean;
};


export function toTask(
  item: TaskApiItem,
): Task {
  return {
    id: item.task_id,
    title: item.task_title,
    completed: item.done,
  };
}

src/features/tasks/guards.ts — 전체 파일

import type {
  Task,
} from './model.js';


export function isTask(
  value: unknown,
): value is Task {
  if (
    typeof value !==
      'object' ||
    value === null
  ) {
    return false;
  }


  return (
    'id' in value &&
    typeof value.id ===
      'string' &&
    'title' in value &&
    typeof value.title ===
      'string' &&
    'completed' in value &&
    typeof value.completed ===
      'boolean'
  );
}


export function isTaskArray(
  value: unknown,
): value is Task[] {
  return (
    Array.isArray(value) &&
    value.every(isTask)
  );
}

src/features/tasks/api.ts — 전체 파일

import type { Task } from './model.js';
import { isTaskArray } from './guards.js';
export async function getTasks():
  Promise<Task[]> {
  const response =
    await fetch('/api/tasks');


  if (!response.ok) {
    throw new Error(
      '작업 요청 실패',
    );
  }


  const data:
    unknown =
    await response.json();


  if (!isTaskArray(data)) {
    throw new Error(
      '작업 데이터 형식 오류',
    );
  }


  return data;
}

export { createTask, updateTask } from '../../core/edit.js';

src/features/tasks/index.ts — 전체 파일

export type {
  Task,
  TaskFilter,
  CreateTaskInput,
  UpdateTaskInput,
} from './model.js';


export {
  getTasks,
  createTask,
  updateTask,
} from './api.js';

완료 체크포인트

typescript-step-21-public-types

다음 학습

마지막 글에서는 TypeScript 7의 네이티브 컴파일러와 기본 설정 변화, TypeScript 6에서 deprecated된 tsconfig 설정의 제거를 기준으로 프로젝트를 최종 점검합니다.

다음 글: TypeScript 7 전환 기준 TS6 차이와 deprecated tsconfig 정리

글 소개·학습 준비·전체 목차
TypeScript 공개 타입 설계 export type과 라이브러리 경계

TypeScript · 21 / 22 · 고급 학습 · 공개 타입 설계

어떤 타입을 외부에 공개해야 할까요? 사용하는 모듈이 알아야 할 계약만 export하고 원본 응답·중간 계산 타입은 구현 내부에 둡니다. export type과 import type으로 타입 경계를 표시하고, 두 파일 예제에서 공개 여부가 타입 검사에 미치는 영향을 확인합니다.

내부 구현용 타입까지 모두 export하면 다른 파일이 세부 구현에 의존하게 되고, 타입 하나를 바꿀 때 영향 범위가 불필요하게 커질 수 있습니다.

이번 글에서 배우는 내용

  • 공개 타입과 내부 타입의 차이
  • export type
  • import type
  • 모듈 경계
  • barrel export
  • public API 파일
  • 구현 세부 타입을 숨기는 기준
  • 타입 파일을 과도하게 분리하지 않는 기준

단독 학습 선수 지식

ES Module export/import, 도메인 타입과 type-only import을 알면 됩니다. 이전 TaskFlow 파일 없이 직접 확인 예제로 학습할 수 있습니다.

목차

이 글이 도움이 되었나요?

조회 중

읽음 기록

읽음 — / 전체 22개 · 필수 21개

이 글을 쓴 해비

웹사이트를 제작·운영하고, 개인 서비스와 자동화·AI 도구를 만듭니다. 직접 만들며 배운 내용과 문제 해결 과정을 BlogFlow에 기록합니다.

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기