타입 어노테이션 — 언제 명시하고 언제 생략할까
TypeScript 타입 어노테이션의 문법, 어노테이션이 필요한 경우와 추론에 맡길 경우를 구분하는 기준, 공개 API와 내부 로직에서의 권장 패턴을 정리합니다.
지난 글에서 TypeScript의 타입 추론 규칙을 살펴봤다. 추론이 강력하다는 것은 알겠는데, 그렇다면 어노테이션은 언제 써야 할까? 이번 글에서는 타입 어노테이션(Type Annotation) 의 문법부터 실전 가이드라인까지 체계적으로 정리한다.
타입 어노테이션 문법
TypeScript의 타입 어노테이션은 변수·파라미터·반환값 뒤에 : 타입 형식으로 붙인다.
// 변수 어노테이션
let name: string = "Alice";
let age: number = 30;
let active: boolean = true;
// 배열
let items: string[] = [];
let matrix: number[][] = [[1, 2], [3, 4]];
// 객체
let user: { name: string; age: number } = { name: "Bob", age: 25 };
// 유니언
let id: string | number = "abc-123";
id = 42; // OK
// 함수 파라미터와 반환 타입
function greet(name: string): string {
return `Hello, ${name}`;
}
// 화살표 함수
const add = (a: number, b: number): number => a + b;
// 변수에 함수 타입 명시
let handler: (event: MouseEvent) => void;
타입 어노테이션은 컴파일 타임에만 존재한다. JavaScript로 트랜스파일되면 모두 제거되어 런타임에는 영향을 주지 않는다.
추론에 맡기는 것이 좋은 경우
TypeScript 팀과 커뮤니티 스타일 가이드는 추론이 명확한 곳에서는 어노테이션을 생략하도록 권장한다. 중복 어노테이션은 코드를 장황하게 만들고 유지보수를 어렵게 한다.
// ❌ 불필요한 어노테이션 — 추론이 완벽히 동작함
const x: number = 42;
const s: string = "hello";
const b: boolean = true;
const arr: number[] = [1, 2, 3];
// ✅ 추론에 맡기기
const x = 42;
const s = "hello";
const b = true;
const arr = [1, 2, 3];
함수 반환 타입도 내부 로직이 단순하면 추론에 맡기는 것이 깔끔하다.
// ✅ 추론 사용 — 단순 계산
function double(n: number) {
return n * 2; // 반환 타입: number — 자명함
}
// ✅ 추론 사용 — 리터럴 반환
function getStatus() {
return "active"; // 반환 타입: string — 자명함
}
// ✅ 콜백 파라미터는 문맥적 타이핑 활용
[1, 2, 3].map((n) => n * 2); // n: number — 어노테이션 불필요
객체를 직접 생성하는 경우에도 추론이 잘 동작한다.
// ✅ 객체 리터럴 반환 — 추론이 정확함
function makeConfig() {
return {
host: "localhost",
port: 3000,
debug: false,
};
}
// 반환 타입: { host: string; port: number; debug: boolean }
어노테이션이 필요한 경우
추론이 불가능하거나, 추론 결과가 의도와 다르거나, 코드의 의도를 명확히 표현해야 할 때 어노테이션을 사용한다.
빈 컨테이너 초기화
// ❌ never[] 또는 암묵적 any — 이후 push 불가
const items = [];
// ✅ 타입 명시
const items: string[] = [];
const map = new Map<string, number>();
const set = new Set<User>();
나중에 채워지는 변수
// ❌ null로 초기화 — TypeScript가 타입을 알 수 없음
let currentUser = null; // null 타입만으로 추론됨
// ✅ 유니언으로 명시
let currentUser: User | null = null;
currentUser = fetchUser(1); // 나중에 User 할당 가능
타입 단언(assertion)보다 어노테이션이 안전한 경우
// ❌ as 사용 — 잘못된 타입을 강제할 수 있어 위험
const el = document.getElementById("root") as HTMLButtonElement;
// ✅ 변수 타입을 올바르게 선언
const el: HTMLElement | null = document.getElementById("root");
if (el instanceof HTMLButtonElement) {
el.click(); // 타입 좁히기로 안전하게 접근
}
여러 타입이 가능한 반환 경로
// 추론 결과가 의도와 다를 수 있음
function parse(input: string) {
if (!input) return null;
return JSON.parse(input); // any — 추론 실패
}
// ✅ 명시로 의도 표현
function parse(input: string): Record<string, unknown> | null {
if (!input) return null;
return JSON.parse(input);
}
함수 파라미터와 반환 타입
함수는 어노테이션 전략에서 가장 중요한 부분이다. 파라미터와 반환 타입에 대한 명확한 기준이 필요하다.
파라미터: 항상 명시
TypeScript는 함수 파라미터를 추론하지 않는다(문맥적 타이핑 제외). strict 모드에서는 어노테이션 없는 파라미터가 암묵적 any로 처리되어 에러가 발생한다.
// ❌ strict 모드에서 에러
function process(data) { // TS7006: 암묵적 any
return data.trim();
}
// ✅ 파라미터는 항상 명시
function process(data: string): string {
return data.trim();
}
// 복잡한 파라미터
function render(
template: string,
context: Record<string, unknown>,
options?: { escapeHtml?: boolean },
): string {
// ...
return template;
}
반환 타입: 공개 API는 명시, 내부 함수는 선택
// ✅ 내부 헬퍼 — 추론으로 충분
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
// ✅ 공개 API — 반환 타입 명시 권장
export function formatCurrency(
amount: number,
currency: string,
): string {
return new Intl.NumberFormat("ko-KR", { style: "currency", currency }).format(amount);
}
// ✅ Promise 반환 — 명시로 의도 명확화
async function fetchUser(id: number): Promise<User> {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
반환 타입을 명시하면 두 가지 이점이 있다. 첫째, 함수의 계약(contract)이 명확해져 호출자가 기대할 수 있는 타입이 분명해진다. 둘째, 구현이 변경될 때 의도치 않게 반환 타입이 바뀌는 것을 컴파일러가 감지해 준다.
공개 API 설계에서의 어노테이션
라이브러리나 모듈의 공개 API를 설계할 때 어노테이션은 선택이 아니라 필수에 가깝다.
// ✅ 공개 인터페이스 — 명시적 타입으로 계약 정의
export interface UserService {
getUser(id: number): Promise<User>;
createUser(data: CreateUserInput): Promise<User>;
updateUser(id: number, data: UpdateUserInput): Promise<User>;
deleteUser(id: number): Promise<void>;
}
// ✅ 구현체도 반환 타입 명시
export class UserServiceImpl implements UserService {
async getUser(id: number): Promise<User> {
// ...
return { id, name: "Alice", email: "alice@example.com" };
}
async createUser(data: CreateUserInput): Promise<User> {
// ...
return { id: 1, ...data };
}
}
이렇게 하면 UserService를 구현하는 어떤 클래스든 동일한 계약을 따르도록 강제할 수 있다. 타입 추론에 의존하는 구현체라면 인터페이스와의 불일치가 나중에야 발견될 수 있다.
// ✅ 제네릭 유틸리티 함수 — 타입 매개변수 명시
export function groupBy<T, K extends string>(
items: T[],
keyFn: (item: T) => K,
): Record<K, T[]> {
return items.reduce(
(acc, item) => {
const key = keyFn(item);
(acc[key] ??= []).push(item);
return acc;
},
{} as Record<K, T[]>,
);
}
실전 체크리스트
어노테이션 여부를 결정할 때 다음 질문을 따라가면 쉽게 판단할 수 있다.
// ✅ 체크리스트 요약
// 1. 파라미터 → 항상 명시
function f(x: string, y: number): void { /* ... */ }
// 2. 공개 export 함수 → 반환 타입 명시
export function compute(n: number): number { return n * 2; }
// 3. 초기값이 명확한 변수 → 추론
const port = 3000; // number
const host = "localhost"; // string
// 4. 빈 배열 / null 초기화 → 명시
const list: string[] = [];
let token: string | null = null;
// 5. 제네릭 타입이 모호할 때 → 명시
const cache = new Map<string, Promise<User>>();
// 6. 함수 타입을 변수에 저장 → 명시 권장
const validator: (input: string) => boolean = (s) => s.length > 0;
// 7. 콜백 파라미터 → 문맥적 타이핑 활용 (인라인 유지)
items.filter((item) => item.active); // item 타입은 문맥에서 결정
타입 어노테이션의 목적은 타입 안전성과 코드 가독성 두 가지다. 추론이 이미 두 가지를 충족한다면 어노테이션은 중복이다. 추론이 불완전하거나 의도를 명확히 표현해야 한다면 어노테이션을 추가한다. 이 원칙을 기준으로 판단하면 대부분의 상황에서 올바른 결정을 내릴 수 있다.
지난 글: 타입 추론 완전 정리 — TypeScript가 타입을 결정하는 방식
읽어주셔서 감사합니다. 😊