배열을 선언할 때, 타입을 지정하지 않으면 타입스크립트는 다음과 같이 타입을 추론합니다.
const numberArray = [1, 2, 3];
// numberArray: number[]
const mixedArray = [1, 'string', false];
// mixedArray: (number | string | boolean)[]
위 배열을 구조분해하여 사용하게 되면,
const [numberFirst, numberTwo, numberThree] = numberArray;
// numberFirst, numberTwo, numberThree: number;
const [mixedFirst, mixedTwo, mixedThree] = mixedArray;
// mixedFirst, mixedTwo, mixedThree: number | string | boolean
mixedFirst의 타입이 number 타입이 아닌, number | string | boolean의 유니온 타입이 추론됩니다.
위와 같이 추론되는 이유는 무엇일까요?
배열은 어떤 값들을 포함하고 있는 리스트 형태의 자료구조입니다.
배열에 포함될 수 있는 값의 형태에는 제약이 없습니다. 따라서, 어떠한 값이든 배열 안에 넣을 수 있습니다.
따라서, 타입스크립트는 타입을 지정하지 않으면, 배열 안에 포함된 모든 요소들의 타입을 한 요소의 타입으로 추론합니다.
공식 문서에 타입 추론 방식에 대해 소개한 부분이 있습니다. 참고하시면 좋을 것 같습니다. Type Inference - Best common type
배열을 구조분해하여 각 요소를 다른 곳에서 사용해야 할 때, 이러한 타입스크립트의 특징은 타입 오류를 발생시킬 수 있습니다.
이와 관련하여 제가 겪은 오류를 하나 소개하겠습니다.
function useFormInput<T extends { [key: string]: string }>(
initialValue: T | (() => T)
) {
const [value, setValue] = useState<T>(initialValue);
const onChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setValue(prev => ({ ...prev, [e.target.name]: e.target.value }));
}, []);
return [value, onChange];
};
function App() {
const [value, onChange] = useFormInput(initValue);
}
위 코드는 제가 만든 커스텀 훅입니다.
저는 커스텀 훅이 리턴하는 배열값을 당연히 아래와 같이 추론할 거라고 생각했는데요.
value: T
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
실제로 App 컴포넌트에서 커스텀 훅을 구조분해하여 사용하게 될 경우, 다음과 같이 타입이 추론됩니다. 기대한 것과 다른 타입이 나와버렸습니다.
value: T | (e: React.ChangeEvent<HTMLInputElement>) => void
onChange: T | (e: React.ChangeEvent<HTMLInputElement>) => void
이 값을 타입이 지정된 다른 곳에 적용하거나 사용하려고 하면, 타입 오류가 발생하여 해당 값을 할당할 수 없다는 오류가 발생할 수 있습니다.
코드 작성자인 저는 분명히 배열에 포함된 각 요소의 타입을 정확하게 알고 있지만,
타입을 명시하지 않았기 때문에, 타입스크립트는 타입을 정확히 알지 못하고 유니온 타입을 추론합니다.
그렇다면 이 문제를 어떻게 해결할 수 있을까요? 배열에 타입을 직접 지정하면 됩니다.
배열의 타입을 지정하는 방식에는 크게 2가지가 존재합니다.
Type[], Array<Type>
1. number[], (number | boolean)[]
1-1. Array<number>, Array<number | boolean>
이 방식은 배열의 타입을 지정하는 가장 기본적인 방식입니다.
1과 1-1은 형태만 다를 뿐, 작동하는 방식은 동일합니다.
const numberArray: number[] = [1, 2, 3]; // Array<number>와 동일
const mixedArray: (number | string)[] = ['hello', 1]; // Array<number | string>와 동일
타입 추론 방식과 크게 다르지 않지만, 명시한 타입 외에 다른 타입을 배열 요소로 추가할 수 없습니다.
튜플 타입은 배열의 길이나 배열 요소의 위치를 정확하게 알아야 할 때 사용합니다.
튜플 타입은 다음과 같이 선언합니다.
const rightArray: [number, string] = [1, 'hello'];
const wrongArray: [number, number] = [1, 'hello']; // 타입 오류 발생
튜플 타입으로 배열을 지정하게 되면, 타입스크립트는 더이상 타입을 추론하지 않고 항상 그 자리의 요소가 선언한 타입과 일치하는지 검사합니다.
이 튜플 타입을 이용하면, 요소의 위치마다 특정 타입이 지정되기 때문에 앞서 발생한 타입 추론 오류를 해결할 수 있을 것 같습니다.
튜플 타입에 대해 조금 더 알아보면,
튜플 타입은 항상 특정 위치에 특정 타입이 있어야 하기 때문에, 불변 구조를 갖습니다.
이 때문에, 공식 문서에서도 readonly
를 가능하면 붙여주는 것이 좋다고 직접적으로 나와 있습니다.
하지만, 엄연히 readonly 튜플 타입과 일반 튜플 타입에는 차이가 있습니다.
일반 튜플 타입은 mutable 튜플 타입으로 처리되며, 이 튜플 타입을 매개변수로 갖는 함수에 readonly 튜플 타입을 인자로 전달하면 타입 오류가 발생합니다.
const array: readonly [number, number] = [1, 2]; // mutable tuple type
const readonlyArray = [1, 2] as const; // readonly tuple type
function calculate([x, y]: [number, number]) {
return x * y;
}
calculate(array);
calculate(readonlyArray); // 타입 오류 발생
리액트에서는 상태를 다룰 때, 항상 원본 객체를 변경하지 않고 새로운 객체를 생성하여 불변성을 보장해야 합니다.
이를 위해, 항상 리액트의 상태는 setState를 통해 변경해야 하는데요.
위 예시에서, 커스텀 훅의 리턴값은 상태값 이벤트 핸들러 함수입니다.
그 중에서 상태는 항상 setState
를 통해 변경되기 때문에 직접 이 상태를 변경하는 일은 없을 것 같습니다.
이벤트 핸들러 함수도 마찬가지입니다. 한번 선언한 함수를 다른 값으로 변경할 일이 있을까요?
하지만 readonly
를 붙여줌으로써, 의미론적으로 선언해주는 것도 나쁘지 않다고 생각됩니다. 안 붙여도 큰 차이는 없습니다.
그럼 이제 아까 봤던 커스텀 훅의 리턴값의 타입을 직접 명시해보겠습니다.
타입을 명시하는 방법에는 여러가지가 있지만, 간편하게 as const
를 이용하여 타입을 지정했습니다. const 단언은 readonly 튜플 타입을 선언하는 것과 동일하게 동작합니다.
리턴하는 배열의 요소가 많을 경우에는, 함수의 리턴값으로 타입을 명시하게 되면 자칫 코드가 길어질 수도 있기 때문에 as const
를 사용하면 약간의 이점을 누릴 수도 있습니다.
function useFormInput<T extends { [key: string]: string }>(
initialValue: T | (() => T)
) {
const [value, setValue] = useState<T>(initialValue);
const onChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setValue(prev => ({ ...prev, [e.target.name]: e.target.value }));
}, []);
return [value, onChange] as const;
};
이제 리턴값의 타입이 명확하게 요소마다 할당되는 것을 확인할 수 있습니다.