TypeScript 튜플 타입 - 고정된 길이의 배열
API 응답을 처리하는 코드를 작성한다고 해봅시다. 좌표 데이터가 [위도, 경도] 형태로 온다면?
// ❌ 배열로 선언하면
const location: number[] = [37.5665, 126.9780]; // 서울시청
const latitude = location[0]; // number
const longitude = location[1]; // number
// 문제: 실수로 순서를 바꿔도 타입 체크가 안 됨
const wrongLocation: number[] = [126.9780, 37.5665]; // ⚠️ 경도, 위도 (잘못된 순서)
배열 타입(number[])을 사용하면 두 가지 문제가 있습니다.
- 길이가 정확히 2개여야 한다는 제약이 없음
- 첫 번째가 위도, 두 번째가 경도라는 의미를 표현할 수 없음
튜플(Tuple)을 사용하면 이 문제를 해결할 수 있습니다.
// ✅ 튜플로 선언
type Coordinate = [number, number];
const location: Coordinate = [37.5665, 126.9780];
// 타입이 더 정확함
const latitude: number = location[0]; // number
const longitude: number = location[1]; // number
// 길이가 맞지 않으면 에러
const invalid: Coordinate = [37.5665]; // ❌ Error!
const tooMany: Coordinate = [37.5665, 126.9780, 100]; // ❌ Error!
왜 튜플을 사용해야 할까요?
1. 고정된 구조의 데이터 표현
실무에서는 “정확히 N개의 요소를 가진 배열”이 필요한 경우가 많습니다.
예: React의 useState
React를 사용해보셨다면, useState 훅을 본 적이 있을 겁니다.
const [count, setCount] = useState(0);
여기서 useState의 반환 타입이 바로 튜플입니다!
// React의 실제 타입 정의 (단순화)
function useState<S>(initialState: S): [S, (newState: S) => void] {
// ...
}
const result = useState(0);
// result의 타입: [number, (newState: number) => void]
const count = result[0]; // number
const setCount = result[1]; // (newState: number) => void
배열이 아니라 튜플을 반환하기 때문에:
- 정확히 2개의 요소만 반환됨을 보장
- 첫 번째는 상태값, 두 번째는 setter 함수라는 의미를 표현
2. 함수가 여러 값을 반환할 때
JavaScript에서 함수가 여러 값을 반환하려면 객체나 배열을 사용합니다. 튜플은 이때 타입 안전성을 제공합니다.
배열 vs 튜플 비교:
// ❌ 배열로 반환 (타입이 모호함)
function parseNameBad(fullName: string): string[] {
return fullName.split(' ');
}
const parts = parseNameBad("Alice Johnson");
const firstName = parts[0]; // string | undefined (배열이므로)
const lastName = parts[1]; // string | undefined
// ✅ 튜플로 반환 (타입이 명확함)
function parseNameGood(fullName: string): [string, string] {
const parts = fullName.split(' ');
return [parts[0], parts[1]];
}
const [firstName, lastName] = parseNameGood("Alice Johnson");
// firstName: string
// lastName: string
3. 다양한 타입을 함께 저장
배열은 보통 같은 타입의 요소들을 저장합니다. 하지만 튜플은 각 위치마다 다른 타입을 가질 수 있습니다.
// 사용자 정보: [ID(숫자), 이름(문자열), 활성화(불린)]
type User = [number, string, boolean];
const user: User = [1, "Alice", true];
const id: number = user[0];
const name: string = user[1];
const isActive: boolean = user[2];
// 잘못된 타입을 넣으면 에러
const invalid: User = ["1", "Alice", true]; // ❌ Error!
4. CSV 파일이나 데이터베이스 row 처리
CSV 파일이나 데이터베이스 쿼리 결과는 보통 순서가 고정된 값들의 나열입니다. 바로 튜플이 딱 맞는 구조입니다!
왜 튜플이 유용한가?
- 각 컬럼의 타입을 정확히 표현: ID는 숫자, 이름은 문자열, 나이는 숫자
- 순서가 의미를 가짐: 첫 번째는 항상 ID, 두 번째는 항상 이름
- 타입 안전성: 잘못된 타입으로 파싱하면 컴파일 에러
- 구조 분해로 명확한 변수명:
const [id, name, email, age]처럼 의미 있는 이름 사용
배열로 하면 어떤 문제가?
// ❌ 배열로 처리하면
function parseCsvRowBad(line: string): (string | number)[] {
const parts = line.split(',');
return [
parseInt(parts[0]),
parts[1],
parts[2],
parseInt(parts[3])
];
}
const result = parseCsvRowBad("1,Alice,alice@example.com,25");
const id = result[0]; // 타입: string | number (정확하지 않음!)
const name = result[1]; // 타입: string | number (정확하지 않음!)
// TypeScript가 id가 number인지 확신할 수 없음
// id.toFixed(2); // ❌ Error: 'string | number' 형식에 'toFixed'가 없습니다
✅ 튜플로 처리하면
// CSV: "1,Alice,alice@example.com,25"
type CsvRow = [id: number, name: string, email: string, age: number];
function parseCsvRow(line: string): CsvRow {
const parts = line.split(',');
return [
parseInt(parts[0]), // ID: number
parts[1], // Name: string
parts[2], // Email: string
parseInt(parts[3]) // Age: number
];
}
// 구조 분해로 명확한 변수명
const [id, name, email, age] = parseCsvRow("1,Alice,alice@example.com,25");
// id: number (정확!)
// name: string (정확!)
// email: string (정확!)
// age: number (정확!)
// TypeScript가 타입을 정확히 알기 때문에 메서드 사용 가능
console.log(id.toFixed(0)); // ✅ OK
console.log(name.toUpperCase()); // ✅ OK
console.log(age + 1); // ✅ OK
// 잘못된 접근은 컴파일 에러
// console.log(id.toUpperCase()); // ❌ Error: number에는 toUpperCase가 없음
데이터베이스 쿼리 결과도 마찬가지:
// SQL: SELECT id, name, created_at FROM users WHERE id = 1
// 결과: [1, "Alice", "2024-01-01T00:00:00Z"]
type UserRow = [id: number, name: string, createdAt: string];
function parseUserRow(row: unknown[]): UserRow {
return [
row[0] as number,
row[1] as string,
row[2] as string
];
}
// 여러 행 처리
function parseUserRows(rows: unknown[][]): UserRow[] {
return rows.map(parseUserRow);
}
const users = parseUserRows([
[1, "Alice", "2024-01-01T00:00:00Z"],
[2, "Bob", "2024-01-02T00:00:00Z"]
]);
// 각 사용자 처리
users.forEach(([id, name, createdAt]) => {
console.log(`User ${id}: ${name} (joined ${createdAt})`);
});
// User 1: Alice (joined 2024-01-01T00:00:00Z)
// User 2: Bob (joined 2024-01-02T00:00:00Z)
객체 vs 튜플 - 언제 뭘 쓸까?
// 튜플: 컬럼 순서가 명확하고, 간단한 경우
type UserTuple = [number, string, string];
const user1: UserTuple = [1, "Alice", "2024-01-01"];
// 객체: 컬럼이 많거나, 의미가 중요한 경우
type UserObject = {
id: number;
name: string;
email: string;
age: number;
createdAt: string;
};
const user2: UserObject = {
id: 1,
name: "Alice",
email: "alice@example.com",
age: 25,
createdAt: "2024-01-01"
};
추천:
- CSV 파싱처럼 순서가 고정되고 컬럼이 3-4개 이하: 튜플
- ORM 쿼리 결과처럼 컬럼이 많고 의미가 중요: 객체
먼저, 기초부터 이해하기
튜플이란?
튜플(Tuple)은 고정된 길이와 각 위치마다 정해진 타입을 가진 배열입니다.
일반 배열과 비교해봅시다.
// 일반 배열: 길이 제한 없음, 모든 요소가 같은 타입
const numbers: number[] = [1, 2, 3, 4, 5];
numbers.push(6); // ✅ OK
numbers[10] = 100; // ✅ OK (길이 제한 없음)
// 튜플: 길이 고정, 각 위치마다 다른 타입 가능
const point: [number, number] = [10, 20];
point.push(30); // ⚠️ 런타임에는 가능하지만 의미상 이상함
point[2] = 40; // ❌ Error: Index 2 out of bounds
튜플 기본 문법 (Syntax)
1. 튜플 타입 선언
튜플 타입은 대괄호 [] 안에 각 위치의 타입을 순서대로 나열합니다.
// 기본 형태: [타입1, 타입2, 타입3, ...]
type MyTuple = [string, number, boolean];
// 2개 요소 튜플
type Pair = [number, number];
// 3개 요소 튜플
type Triple = [string, number, boolean];
// 각 위치마다 다른 타입 가능
type Mixed = [number, string, boolean, null, undefined];
2. 튜플 값 할당
선언된 튜플 타입에 맞게 값을 할당합니다.
type Person = [string, number]; // [이름, 나이]
// ✅ 올바른 할당
const person1: Person = ["Alice", 25];
// ❌ 타입이 맞지 않음
const person2: Person = [25, "Alice"]; // Error: Type 'number' is not assignable to type 'string'
// ❌ 길이가 맞지 않음
const person3: Person = ["Bob"]; // Error: Type '[string]' is not assignable to type 'Person'
const person4: Person = ["Bob", 30, true]; // Error: Type '[string, number, boolean]' is not assignable to type 'Person'
// ❌ 타입이 완전히 틀림
const person5: Person = "Alice"; // Error: Type 'string' is not assignable to type 'Person'
3. 튜플 요소 접근
배열처럼 인덱스로 접근하지만, TypeScript가 각 위치의 타입을 정확히 알고 있습니다.
type Coordinate = [number, number];
const point: Coordinate = [10, 20];
// 인덱스로 접근
const x = point[0]; // 타입: number
const y = point[1]; // 타입: number
// TypeScript는 각 위치의 타입을 정확히 알고 있음
x.toFixed(2); // ✅ OK - number 메서드
// x.toUpperCase(); // ❌ Error - string 메서드는 불가
// 범위를 벗어난 접근
const z = point[2]; // ❌ Error: Tuple type 'Coordinate' of length '2' has no element at index '2'
배열과 비교:
// 배열: 모든 인덱스가 같은 타입
const arr: number[] = [10, 20];
const item1 = arr[0]; // 타입: number | undefined (없을 수도 있음)
const item2 = arr[100]; // 타입: number | undefined (에러 안 남!)
// 튜플: 각 인덱스마다 정확한 타입
const tuple: [number, string] = [10, "hello"];
const first = tuple[0]; // 타입: number (정확히!)
const second = tuple[1]; // 타입: string (정확히!)
// const third = tuple[2]; // ❌ 컴파일 에러
4. 구조 분해 할당 (Destructuring)
튜플은 구조 분해 할당과 완벽하게 작동합니다.
type User = [string, number, boolean];
const user: User = ["Alice", 25, true];
// 구조 분해 할당
const [name, age, isActive] = user;
// name: string
// age: number
// isActive: boolean
console.log(name); // "Alice"
console.log(age); // 25
console.log(isActive); // true
// 일부만 추출
const [username] = user;
console.log(username); // "Alice"
const [, userAge] = user; // 첫 번째 건너뛰기
console.log(userAge); // 25
// 나머지 연산자 사용
type Coords = [number, number, number];
const coords: Coords = [10, 20, 30];
const [x, ...rest] = coords;
// x: number (10)
// rest: [number, number] (튜플!) ([20, 30])
5. 타입 추론
TypeScript는 값을 보고 타입을 추론할 수 있습니다.
// 타입을 명시하지 않으면 배열로 추론됨
const tuple1 = [1, "hello"];
// 타입: (string | number)[] ← 배열!
// as const를 사용하면 정확한 literal 튜플로 추론
const tuple2 = [1, "hello"] as const;
// 타입: readonly [1, "hello"] ← literal 튜플!
// 타입을 명시하면 튜플
const tuple3: [number, string] = [1, "hello"];
// 타입: [number, string] ← 튜플!
// 함수 반환값도 명시 필요
function getPair() {
return [1, "hello"]; // 타입: (string | number)[]
}
function getPairTuple(): [number, string] {
return [1, "hello"]; // 타입: [number, string]
}
6. 선언 방법 정리
// 방법 1: 인라인 타입 지정
const tuple1: [string, number] = ["Alice", 25];
// 방법 2: type alias 사용 (추천 - 재사용 가능)
type Person = [string, number];
const tuple2: Person = ["Bob", 30];
const tuple3: Person = ["Charlie", 35];
// 방법 3: interface는 튜플에 사용 불가
// interface PersonInterface extends [string, number] {} // ❌ Error
// 방법 4: const assertion (readonly literal)
const tuple4 = ["David", 40] as const;
// 타입: readonly ["David", 40]
// 방법 5: 제네릭과 함께
type Pair<T> = [T, T];
const numbers: Pair<number> = [10, 20];
const strings: Pair<string> = ["hello", "world"];
배열 vs 튜플 비교표
| 특징 | 배열 number[] |
튜플 [number, string] |
|---|---|---|
| 길이 | 가변 | 고정 |
| 요소 타입 | 모두 동일 | 위치마다 다를 수 있음 |
| 접근 | arr[i] (타입: number \| undefined) |
tuple[0] (타입: number), tuple[1] (타입: string) |
| 용도 | 동일한 데이터의 컬렉션 | 고정된 구조의 데이터 |
| 예시 | [1, 2, 3, 4] |
["Alice", 25] |
튜플 활용 패턴
실무에서 튜플을 어떻게 사용하는지 살펴봅시다.
패턴 1: 키-값 쌍 (Key-Value Pair)
type Entry<K, V> = [K, V];
const entries: Entry<string, number>[] = [
["apple", 100],
["banana", 200],
["cherry", 300]
];
// Map으로 변환
const map = new Map(entries);
console.log(map.get("apple")); // 100
// Object.entries() 반환 타입도 튜플!
const obj = { a: 1, b: 2 };
const objEntries = Object.entries(obj);
// 타입: [string, number][]
패턴 2: 함수의 매개변수와 반환값
// 여러 값을 반환
function divideWithRemainder(dividend: number, divisor: number): [number, number] {
const quotient = Math.floor(dividend / divisor);
const remainder = dividend % divisor;
return [quotient, remainder];
}
const [quotient, remainder] = divideWithRemainder(10, 3);
console.log(`${quotient} 나머지 ${remainder}`); // "3 나머지 1"
// 성공/실패 결과 반환 (Result 패턴)
type Result<T, E> = [true, T] | [false, E];
function divide(a: number, b: number): Result<number, string> {
if (b === 0) {
return [false, "Division by zero"];
}
return [true, a / b];
}
const [success, value] = divide(10, 2);
if (success) {
console.log(value); // number
} else {
console.error(value); // string
}
패턴 3: 범위 표현
type Range = [number, number]; // [start, end]
function createRange(start: number, end: number): Range {
return [start, end];
}
const ageRange: Range = [20, 30];
const priceRange: Range = [1000, 5000];
function isInRange(value: number, [min, max]: Range): boolean {
return value >= min && value <= max;
}
console.log(isInRange(25, ageRange)); // true
console.log(isInRange(35, ageRange)); // false
패턴 4: 상태 머신의 전환 (Transition)
type State = "idle" | "loading" | "success" | "error";
type Transition = [State, State]; // [from, to]
const transitions: Transition[] = [
["idle", "loading"],
["loading", "success"],
["loading", "error"],
["error", "idle"],
["success", "idle"]
];
function isValidTransition([from, to]: Transition): boolean {
return transitions.some(([f, t]) => f === from && t === to);
}
console.log(isValidTransition(["idle", "loading"])); // true
console.log(isValidTransition(["success", "error"])); // false
패턴 5: 좌표와 벡터
type Point2D = [number, number];
type Point3D = [number, number, number];
function distance(p1: Point2D, p2: Point2D): number {
const [x1, y1] = p1;
const [x2, y2] = p2;
return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
}
const pointA: Point2D = [0, 0];
const pointB: Point2D = [3, 4];
console.log(distance(pointA, pointB)); // 5
// RGB 색상
type RGB = [number, number, number];
const red: RGB = [255, 0, 0];
const green: RGB = [0, 255, 0];
const blue: RGB = [0, 0, 255];
튜플 기능
TypeScript 4.0 이후로 튜플에 강력한 기능들이 추가되었습니다.
1. 레이블이 있는 튜플 (Labeled Tuples)
TypeScript 4.0부터 튜플 요소에 레이블을 붙일 수 있습니다.
// 레이블 없음 (이전 방식)
type PersonOld = [string, number];
const person1: PersonOld = ["Alice", 25];
// 첫 번째가 이름인지 나이인지 헷갈림
// 레이블 있음 (TypeScript 4.0+)
type PersonNew = [name: string, age: number];
const person2: PersonNew = ["Bob", 30];
// IDE에서 자동완성이 더 명확해짐
// person2[0]에 마우스 올리면 "name: string"이 표시됨
레이블은 문서화 목적이며, 런타임에는 영향을 주지 않습니다. 하지만 코드 가독성이 크게 향상됩니다.
// 함수 시그니처가 더 명확해짐
type ApiResponse = [
status: number,
data: unknown,
headers: Record<string, string>
];
function handleResponse([status, data, headers]: ApiResponse) {
// 매개변수 이름만 봐도 의미를 알 수 있음
}
2. 옵셔널 요소 (Optional Elements)
튜플의 일부 요소를 선택적으로 만들 수 있습니다.
// 마지막 요소가 선택적
type Point = [x: number, y: number, z?: number];
const point2D: Point = [10, 20]; // ✅ OK
const point3D: Point = [10, 20, 30]; // ✅ OK
// 여러 개의 옵셔널 요소
type Person = [name: string, age: number, email?: string, phone?: string];
const person1: Person = ["Alice", 25];
const person2: Person = ["Bob", 30, "bob@example.com"];
const person3: Person = ["Charlie", 35, "charlie@example.com", "010-1234-5678"];
주의: 옵셔널 요소는 항상 끝에 와야 합니다.
// ❌ 잘못된 예: 옵셔널 요소 뒤에 필수 요소
type Invalid = [name?: string, age: number]; // Error!
// ✅ 올바른 예
type Valid = [name: string, age?: number];
3. Rest 요소 (Rest Elements)
튜플에서 나머지 요소를 배열로 받을 수 있습니다.
// 첫 두 요소는 고정, 나머지는 가변
type StringNumberBooleans = [string, number, ...boolean[]];
const valid1: StringNumberBooleans = ["hello", 1];
const valid2: StringNumberBooleans = ["hello", 1, true];
const valid3: StringNumberBooleans = ["hello", 1, true, false, true];
// ❌ 첫 두 요소가 맞지 않으면 에러
const invalid: StringNumberBooleans = ["hello"]; // Error!
// Rest 요소는 중간에도 올 수 있음 (TypeScript 4.2+)
type StringNumbersBoolean = [string, ...number[], boolean];
const example1: StringNumbersBoolean = ["hello", true];
const example2: StringNumbersBoolean = ["hello", 1, 2, 3, true];
실전 예제:
// 첫 번째는 명령어, 나머지는 인자
type Command = [command: string, ...args: string[]];
function executeCommand([command, ...args]: Command) {
console.log(`Executing: ${command}`);
console.log(`Arguments: ${args.join(', ')}`);
}
executeCommand(["git", "commit", "-m", "Initial commit"]);
// Executing: git
// Arguments: commit, -m, Initial commit
4. readonly 튜플
튜플을 불변으로 만들 수 있습니다.
// 읽기 전용 튜플
const point: readonly [number, number] = [10, 20];
point[0] = 30; // ❌ Error: Cannot assign to '0' because it is a read-only property
point.push(30); // ❌ Error: Property 'push' does not exist
// const assertion으로 자동으로 readonly
const location = [37.5665, 126.9780] as const;
// 타입: readonly [37.5665, 126.9780]
// literal 타입이므로 더 정확함!
실전 예제
실무에서 튜플을 어떻게 사용하는지 구체적인 예제를 살펴봅시다.
예제 1: Promise.all()의 반환 타입
여러 비동기 작업을 병렬로 실행할 때:
async function fetchUserData(userId: number) {
const [user, posts, comments] = await Promise.all([
fetchUser(userId), // Promise<User>
fetchPosts(userId), // Promise<Post[]>
fetchComments(userId) // Promise<Comment[]>
]);
// Promise.all의 반환 타입: [User, Post[], Comment[]]
// user: User
// posts: Post[]
// comments: Comment[]
return { user, posts, comments };
}
// 타입 정의
type User = { id: number; name: string };
type Post = { id: number; title: string };
type Comment = { id: number; text: string };
async function fetchUser(id: number): Promise<User> {
// API 호출
return { id, name: "Alice" };
}
async function fetchPosts(userId: number): Promise<Post[]> {
return [{ id: 1, title: "First Post" }];
}
async function fetchComments(userId: number): Promise<Comment[]> {
return [{ id: 1, text: "Nice post!" }];
}
예제 2: 데이터 변환 파이프라인
type TransformResult<T> = [success: boolean, data: T | null, error: string | null];
function parseJSON<T>(json: string): TransformResult<T> {
try {
const data = JSON.parse(json);
return [true, data, null];
} catch (error) {
return [false, null, error instanceof Error ? error.message : "Unknown error"];
}
}
function validateUser(data: unknown): TransformResult<User> {
if (typeof data === 'object' && data !== null && 'name' in data && 'age' in data) {
return [true, data as User, null];
}
return [false, null, "Invalid user data"];
}
// 사용
const jsonString = '{"name": "Alice", "age": 25}';
const [parseSuccess, parseData, parseError] = parseJSON(jsonString);
if (!parseSuccess) {
console.error("Parse error:", parseError);
throw new Error(parseError!);
}
const [validateSuccess, userData, validateError] = validateUser(parseData);
if (!validateSuccess) {
console.error("Validation error:", validateError);
throw new Error(validateError!);
}
console.log("Valid user:", userData);
예제 3: 이벤트 핸들러
// 이벤트 타입과 핸들러를 튜플로 관리
type EventMap = {
click: [x: number, y: number];
keypress: [key: string, code: number];
scroll: [scrollX: number, scrollY: number];
};
type EventHandler<K extends keyof EventMap> = (...args: EventMap[K]) => void;
class EventEmitter {
private handlers = new Map<keyof EventMap, Function[]>();
on<K extends keyof EventMap>(event: K, handler: EventHandler<K>) {
if (!this.handlers.has(event)) {
this.handlers.set(event, []);
}
this.handlers.get(event)!.push(handler);
}
emit<K extends keyof EventMap>(event: K, ...args: EventMap[K]) {
const eventHandlers = this.handlers.get(event);
if (eventHandlers) {
eventHandlers.forEach(handler => handler(...args));
}
}
}
// 사용
const emitter = new EventEmitter();
emitter.on('click', (x, y) => {
console.log(`Clicked at (${x}, ${y})`);
});
emitter.on('keypress', (key, code) => {
console.log(`Key pressed: ${key} (${code})`);
});
emitter.emit('click', 100, 200); // ✅ OK
emitter.emit('keypress', 'A', 65); // ✅ OK
// emitter.emit('click', 'invalid'); // ❌ Error!
예제 4: 데이터베이스 쿼리 결과
// SQL 쿼리 결과를 튜플로 타입 지정
type QueryResult = [
id: number,
name: string,
email: string,
createdAt: Date
];
function parseQueryResult(row: unknown[]): QueryResult {
return [
row[0] as number,
row[1] as string,
row[2] as string,
new Date(row[3] as string)
];
}
// 여러 행 처리
function parseQueryResults(rows: unknown[][]): QueryResult[] {
return rows.map(parseQueryResult);
}
// 사용
const rawResults = [
[1, "Alice", "alice@example.com", "2024-01-01"],
[2, "Bob", "bob@example.com", "2024-01-02"]
];
const results = parseQueryResults(rawResults);
results.forEach(([id, name, email, createdAt]) => {
console.log(`User ${id}: ${name} (${email}) - joined ${createdAt.toLocaleDateString()}`);
});
함정과 주의사항
튜플을 사용할 때 주의해야 할 점들이 있습니다.
1. push/pop은 막을 수 없음
TypeScript의 튜플은 런타임에서는 그냥 배열입니다. 따라서 push, pop 같은 배열 메서드를 막을 수 없습니다.
const tuple: [number, string] = [1, "hello"];
tuple.push(2); // ⚠️ TypeScript는 경고하지만, 런타임에서는 동작함
tuple.pop(); // ⚠️ 마찬가지
console.log(tuple); // [1, "hello", 2] 또는 [1]
해결책: readonly를 사용하세요:
const tuple: readonly [number, string] = [1, "hello"];
tuple.push(2); // ❌ Error: Property 'push' does not exist
tuple.pop(); // ❌ Error: Property 'pop' does not exist
2. 구조 분해 할당 시 순서 주의
튜플을 구조 분해할 때 순서를 바꾸면 의미가 바뀝니다.
type Point = [x: number, y: number];
const point: Point = [10, 20];
// ✅ 올바른 순서
const [x, y] = point;
console.log(x, y); // 10, 20
// ❌ 잘못된 순서 (TypeScript는 못 잡음!)
const [y2, x2] = point;
console.log(x2, y2); // 20, 10 (의도와 다름)
변수 이름을 명확하게 지으면 실수를 줄일 수 있습니다.
3. 배열 메서드 사용 시 타입 손실
배열 메서드를 사용하면 튜플 타입이 손실될 수 있습니다.
const tuple: [number, string] = [1, "hello"];
// map을 사용하면 튜플 타입이 사라짐
const mapped = tuple.map(x => x);
// 타입: (string | number)[] ← 튜플이 아님!
// filter도 마찬가지
const filtered = tuple.filter(x => typeof x === 'number');
// 타입: (string | number)[] ← 튜플이 아님!
이는 TypeScript의 한계입니다. 배열 메서드는 튜플 타입을 유지하지 못합니다.
해결책: 타입 단언 사용:
const mapped = tuple.map(x => x) as [number, string];
4. 옵셔널 요소와 undefined 구분
옵셔널 요소와 undefined를 명시적으로 넣는 것은 다릅니다.
type Tuple = [string, number?];
const tuple1: Tuple = ["hello"]; // ✅ OK
const tuple2: Tuple = ["hello", 10]; // ✅ OK
const tuple3: Tuple = ["hello", undefined]; // ✅ OK (하지만 명시적으로 undefined)
console.log(tuple1.length); // 1
console.log(tuple2.length); // 2
console.log(tuple3.length); // 2 ← undefined를 넣으면 길이가 2!
5. 인덱스 접근 범위
튜플의 길이를 넘어서 접근하면 컴파일 에러:
type Pair = [number, number];
const pair: Pair = [10, 20];
const first = pair[0]; // ✅ OK: number
const second = pair[1]; // ✅ OK: number
const third = pair[2]; // ❌ Error: Tuple type 'Pair' of length '2' has no element at index '2'
// 하지만 변수 인덱스는 체크 못함
const index = 2;
const value = pair[index]; // ⚠️ TypeScript는 체크 못함, 런타임 에러 가능성
6. 객체 vs 튜플 선택
때로는 튜플보다 객체가 더 명확할 수 있습니다.
// ❌ 튜플: 순서를 기억해야 함
type PersonTuple = [string, number, string];
const person1: PersonTuple = ["Alice", 25, "alice@example.com"];
// 어느 게 이름이고 어느 게 이메일인지 헷갈림
// ✅ 객체: 의미가 명확함
type PersonObject = {
name: string;
age: number;
email: string;
};
const person2: PersonObject = {
name: "Alice",
age: 25,
email: "alice@example.com"
};
튜플을 사용하기 좋은 경우:
- 요소가 2-3개 이하
- 순서가 의미를 가짐 (좌표, 범위 등)
- 기존 API가 튜플 형태 (React useState 등)
객체를 사용하기 좋은 경우:
- 요소가 4개 이상
- 각 필드의 의미가 중요
- 나중에 필드를 추가할 가능성
튜플 vs 다른 타입 비교
튜플 vs 배열
// 배열: 같은 타입, 가변 길이
const numbers: number[] = [1, 2, 3];
numbers.push(4); // ✅ OK
// 튜플: 다른 타입 가능, 고정 길이
const tuple: [string, number] = ["Alice", 25];
tuple[2] = true; // ❌ Error
튜플 vs 객체
// 튜플: 순서 기반
const personTuple: [string, number] = ["Alice", 25];
const name = personTuple[0]; // 인덱스로 접근
// 객체: 키 기반
const personObj = { name: "Alice", age: 25 };
const name2 = personObj.name; // 키로 접근
튜플 vs Union 타입
// Union: 하나의 타입만 가능
const value: string | number = "hello"; // 또는 123
// 튜플: 여러 타입을 동시에 가짐
const tuple: [string, number] = ["hello", 123];
마치며
튜플은 TypeScript에서 “고정된 구조의 데이터”를 표현하는 강력한 도구입니다.
핵심을 정리하면:
- 고정된 길이: 정확히 N개의 요소
- 타입 안전성: 각 위치마다 정해진 타입
- 의미 표현: 순서가 의미를 가지는 데이터 (좌표, 범위, 키-값 쌍 등)
- 배열과 차이: 배열은 가변 길이 + 동일 타입, 튜플은 고정 길이 + 다양한 타입
실무에서 활용하려면:
- React useState처럼 여러 값을 반환하는 함수
- 좌표, 범위, RGB 색상 같은 고정 구조 데이터
- CSV 파싱이나 데이터베이스 row 처리
readonly로 불변성 보장- 레이블로 가독성 향상
주의사항:
push/pop은readonly로 방지- 배열 메서드 사용 시 타입 손실
- 너무 많은 요소는 객체 사용 고려
다음 단계:
- 튜플을 사용하는 실제 프로젝트 코드 작성해보기
- React의
useState반환 타입 분석하기 - 유틸리티 타입과 튜플 결합하기 (
Parameters,ReturnType등)
튜플은 처음에는 “배열이랑 뭐가 다르지?”라고 생각할 수 있지만, 제대로 활용하면 코드의 타입 안전성과 가독성을 크게 향상시킬 수 있습니다.
참고 자료
공식 문서
- TypeScript Handbook - Tuple Types
- TypeScript 4.0 Release Notes - Labeled Tuple Elements
- TypeScript 4.2 Release Notes - Rest Elements
댓글