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[])을 사용하면 두 가지 문제가 있습니다.

  1. 길이가 정확히 2개여야 한다는 제약이 없음
  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 파일이나 데이터베이스 쿼리 결과는 보통 순서가 고정된 값들의 나열입니다. 바로 튜플이 딱 맞는 구조입니다!

왜 튜플이 유용한가?

  1. 각 컬럼의 타입을 정확히 표현: ID는 숫자, 이름은 문자열, 나이는 숫자
  2. 순서가 의미를 가짐: 첫 번째는 항상 ID, 두 번째는 항상 이름
  3. 타입 안전성: 잘못된 타입으로 파싱하면 컴파일 에러
  4. 구조 분해로 명확한 변수명: 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에서 “고정된 구조의 데이터”를 표현하는 강력한 도구입니다.

핵심을 정리하면:

  1. 고정된 길이: 정확히 N개의 요소
  2. 타입 안전성: 각 위치마다 정해진 타입
  3. 의미 표현: 순서가 의미를 가지는 데이터 (좌표, 범위, 키-값 쌍 등)
  4. 배열과 차이: 배열은 가변 길이 + 동일 타입, 튜플은 고정 길이 + 다양한 타입

실무에서 활용하려면:

  • React useState처럼 여러 값을 반환하는 함수
  • 좌표, 범위, RGB 색상 같은 고정 구조 데이터
  • CSV 파싱이나 데이터베이스 row 처리
  • readonly로 불변성 보장
  • 레이블로 가독성 향상

주의사항:

  • push/popreadonly로 방지
  • 배열 메서드 사용 시 타입 손실
  • 너무 많은 요소는 객체 사용 고려

다음 단계:

  • 튜플을 사용하는 실제 프로젝트 코드 작성해보기
  • React의 useState 반환 타입 분석하기
  • 유틸리티 타입과 튜플 결합하기 (Parameters, ReturnType 등)

튜플은 처음에는 “배열이랑 뭐가 다르지?”라고 생각할 수 있지만, 제대로 활용하면 코드의 타입 안전성과 가독성을 크게 향상시킬 수 있습니다.

참고 자료

공식 문서

아티클

실전 사용 사례

댓글