[타입스크립트 뽀개기] Basic: Everyday Types(2)

Jihan·2024년 3월 26일
0
post-thumbnail

앞서 소개한 기본적인 타입과 선언법, 그리고 타입 별칭 등을 활용하면 충분히 대부분의 변수에 대한 타입 선언이 가능하다. 하지만 우리는 아직 배열이나 특정 함수들의 타입 등은 어떻게 지정하는지 배우지 못했다. 지금까지 배운 것 뿐만 아니라 TS의 세상에는 타입에러를 막기 위해서 굉장히 다양한 기능들이 제공되고 있다. 이번 글에서는 앞서 다루지 않은 다양한 타입들에 대해 알아보겠다.


Array

선형 자료구조 중에서 가장 대표적인 배열에 대한 타입이다. 배열로 선언된 타입은 숫자 인덱스로 배열의 요소들을 탐색할 수 있다. 배열 타입의 선언은 크게 두 가지 방법으로 할 수 있는데, 꺽쇄(’<’, ‘>’)를 활용한 방식은 ‘제너릭 타입’이라는 선언 방식이며, 뒷 편에서 자세하게 다룰 것이다.

const numArray: number[] = [1, 2, 3]
numArray.push('a')
// Argument of type 'string' is not assignable to parameter of type 'number'.

const strArray: Array<string> = ['a', 'b', 'c']
strArray.push(1)
// Argument of type 'number' is not assignable to parameter of type 'string'.

항상 배열 등의 특수한 타입을 선언할 때는 아래와 같이 유니온 범위들이 적절하게 작동할 수 있는지 유의하여 작성해주어야 한다. 아래의 myArray는 number array이거나, string array이기 때문에, number와 string을 혼용하여 담을 수 없다.

const myArray: number[] | string[] = ['a', 'b', 1]
// Type '(string | number)[]' is not assignable to type 'number[] | string[]'.
const myArray2: (number | string)[] = ['a', 'b', 1]

Tuple

python을 써본 적이 있거나, C++의 STL을 가져다 써본 적이 있다면 익숙한 이름의 타입이다. Tuple은 간단하게 말해서 내부 구조가 정해져 있는 배열이라고 보면 된다. 일반적으로 사용되는 Tuple 타입은 업데이트가 제한되어있는 자료구조로 활용된다. JS에서는 Tuple 타입을 직접 지원하지 않기 때문에 이러한 기능적인 요소는 포함되지 않고, 단지 구조만을 확정할 수 있다. 우리가 위에서 살펴본 두 가지 배열 타입 선언문을 섞으면 딱 Tuple의 선언법이다. 헷갈린 김에 섞어서 사용하면 다음과 같은 에러가 발생한다.

const myVar: [number] = [1, 2]
// Type '[number, number]' is not assignable to type '[number]'.   Source has 2 element(s) but target allows only 1.

위와 같이 선언된 타입은 배열 타입이 아닌, 튜플 타입으로 인식된다. 튜플 타입의 적절한 선언 및 사용 방법은 아래와 같다.

const myTuple1: [number, string] = [1, 'a']
const myTuple2: [number, string, string, string] = [1, 'a', 'b', 'c']

Function

함수의 타입을 선언하는 방법에 대해서도 알아보자. 함수는 크게 두 가지에 대해 타입을 지정해야 한다. 파라미터 타입과 반환 타입이다. 이전에는 function이라는 타입을 TS에서 지원했지만, 업데이트와 함께 이를 사용할 경우 파라미터와 반환 타입을 정확하게 선언하라는 에러를 볼 수 있다.

파라미터 타입과 반환 타입

함수의 파라미터 타입은 각 파라미터에 대해 아래와 같이 선언할 수 있으며, 반환 타입도 아래와 같이 파라미터 괄호의 우측에 콜론으로 선언해줄 수 있다.

function myFunc(param: string):number{
	return param
}
// Type 'string' is not assignable to type 'number'.

const myFunc2 = (param: string):number => {
    return param
}
// Type 'string' is not assignable to type 'number'.

함수 내에서 지역적으로 타입이 선언되어 있는 변수를 반환하였을 경우에는 자동으로 함수의 반환형이 해당 타입으로 지정된다.

const myFunc = () => {
	const myStr:string = 'hello'
	return myStr
}

const myNum:number = myFunc()
// Type 'string' is not assignable to type 'number'.

함수 타입 저장

앞서 배웠던 type 예약어를 통해서 함수의 파라미터 타입과 반환 타입을 동시에 선언해주고, 함수에 함수 타입을 지정해주는 식으로 사용할 수도 있다.

type functionType = (param1:string, param2: string) => number;

const myFunc: functionType = (param1, param2) => {
    return param1 + param2
}
// Type '(param1: string, param2: string) => string' is not assignable to type 'functionType'.   Type 'string' is not assignable to type 'number'.

그렇다면, 함수 파라미터의 타입에 따라서 반환 타입을 옵셔널하게 지정해줄 수는 없는 걸까? 상상력을 발휘해서 아래와 같이 코드를 작성해보면, ‘typeof param’의 값이 타입으로 선언되어 있는 부분 때문에 문제가 발생하는 것처럼 보인다.

type functionType = (param:string | number) => typeof param;

const myFunc:functionType = (param:string):string => {
    return param
}
// Type '(param: string) => string' is not assignable to type 'functionType'.

그러니까, 유니온 타입으로 param의 타입에 대한 가능성을 두 가지로 열어놨어도, 이 중 하나만 허용하는 함수의 타입을 선언하는 것은 별개의 이야기라는 뜻이다.

이런 경우에는 제너릭 타입을 활용해주어야 한다. 위의 Array 타입에 대한 설명에서도 제너릭 타입의 언급이 있었는데, 마찬가지로 다음 포스팅에서 자세히 다뤄보도록 하자. 이번 포스팅에서는 담을 내용이 아직 많이 남아있다.

콜 시그니쳐의 타입

// 콜 시그니처 외의 멤버를 포함하고 있는 함수 객체 타입 선언
type functionType = {
    _mem: string,
    (param:number):string
}
// 위에서 선언한 함수 객체의 타입으로 함수 선언
const myFunc:functionType = (param:number) => {
    return param.toString();
}
// 함수 객체에 포함된 멤버 이름으로 멤버에 값 할당
myFunc._mem = 'hi';
console.log(myFunc._mem);
// hi

// 멤버를 포함하지 않는 함수 타입 선언
type functionType2 = (param:number) => string;
// 위에서 선언한 함수 객체의 타입으로 함수 선언
const myFunc2:functionType2 = (param:number) => {
    return param.toString();
}
myFunc2._mem = 'hi';
// Property '_mem' does not exist on type 'functionType2'.

Enum(열거형)

enum은 JS에선 제공하지 않는, TS에서 제공하는 타입 중 하나이다. enum은 특정 상수 집합을 선언할 때 활용할 수 있으며, 코드의 관리를 수월하게 해주는 extension이라고 보면 된다. 아래와 같이 enum을 선언하면, enum 선언부 내부에 있는 상수들에 대하여 자동으로 0부터 인덱스가 매겨지고, 인덱스와 상수값들에 대한 매핑과 역매핑이 이루어진 object가 생성된다.

enum myEnum{
    a,
    b
}

console.log(myEnum)
// { "0": "a", "1": "b", "a": 0, "b": 1 }

console.log(myEnum[0])
// "a"
console.log(myEnum[1])
// "b"
console.log(myEnum['a'])
// 0
console.log(myEnum['b'])
// 1

enum은 런타임에 객체를 생성한다.

우리는 enum을 사용할 때 이것이 TS에서 파생된 기능인 것을 알면서도, 일반적인 TS의 기능들과는 큰 차이점이 있다는 것을 인지해야 한다. 특히 enum을 선언하면, 이는 JavaScript 런타임상 객체를 생성한다. 위에서 콘솔에 myEnum을 출력해보았을 때, 자연스럽게 생성된 객체를 참조하여 이를 출력해주는 것을 볼 수 있다. 하지만 TS의 일반적인 타입 선언문을 통해 선언된 타입은 아래와 같이 런타임 함수의 파라미터로 호출할 수 없다.

다시 말해, enum 선언문은 타입 선언과 함께 객체 선언 및 할당의 기능도 함께한다. enum은 타입이자 객체인 것이다.

type myStr = 'string'

console.log(myStr)
// 'myStr' only refers to a type, but is being used as a value here.

우리는 enum을 활용하여 상수 집합을 선언할 수 있으며, 이를 통해 아래와 같이 런타임 상수 집합 참조를 수월하게 할 수 있다.

enum direction {
    up,
    down,
    left,
    right
}

const go = (dir:direction) => {
    console.log(`go ${direction[dir]}`)
}

for(let i = 0; i < 4; i++){
    go(i)
}
// go up
// go down
// go left
// go right

그런데 이런 예제는 [’up’, ‘down’, ‘left’, ‘right’]라는 상수 Array를 선언하여 사용하는 것과 별반 차이가 없을 것처럼 보인다. 물론 위의 코드에서는 enum과 Array를 굳이 구분해서 사용해야할 필요가 안 느껴지지만, 명확하게 있는 차이가 어떤 것인지 알아보자.

enum vs Array

enum은 인덱싱의 값을 임의로 조정할 수 있으며, 위에서도 이야기한 것처럼 역매핑이 가능하다. 아래와 같이 enum을 선언하면 인덱스가 지정되지 않은 값들은 앞에서 가장 가까운 인덱스에서 자동 증가된 값으로 할당된다. 또한 음수 인덱스도 적용되면서, 음수 인덱스 참조도 가능하게 한다.

enum myEnum{
    a = 12,
    b,
    c = -3,
    d
}

console.log(myEnum.b)
// 13

console.log(myEnum.d)
// -2

console.log(myEnum[-2])
// "d"

또한 enum은 string index를 지정해줄 수도 있다. string index를 선언한 경우 위에서 number index가 적용된 것처럼 역매핑과 자동 인덱싱 기능이 적용되지 않는다.

enum myEnum {
    a = 'A',
    b = 'B',
}

console.log(myEnum);
// { "a": "A", "b": "B" }

console.log(myEnum.a);
// "A"

아래와 같이 number index와 string index를 혼용할 수도 있다. 이 때 인덱스를 지정해주지 않은 값들은 자동 인덱싱이 될 수 있는 number index를 가진 값의 뒤에 이어지도록 작성되어야 한다.

enum myEnum {
    a,
    b = 'B',
}

console.log(myEnum);
// { "0": "a", "a": "0", "b"; "B" }

console.log(myEnum.a);
// 0

enum myEnum2 {
    a = "A",
    b
}
// Enum member must have initializer.

enum vs Object

이번에는 Object와 비교해보자. string index를 활용하여 선언된 enum의 경우 object 선언문과 무슨 차이가 있는지 알기 힘들다. 하지만 enum은 앞서 언급했던 것처럼 ‘타입이자 객체’로 선언된다. 다시 말해 생성된 Object에 대한 타입 선언을 별개로 해줄 필요가 없다는 것이다. 아래와 같이 함수 파라미터로 상수 Object의 타입을 사용해야 하는 경우 등에서 일반적인 Object 선언문은 번거로운 타입 선언이 필요해질 수 있다.

enum myEnum {
    a = "A",
    b = "B",
}
const eFunction = (param: myEnum) => {
    console.log(param)
}
eFunction(myEnum.a)
// "A"

const myObject = {
    a: "A",
    b: "B"
}
const oFunction = (param: typeof myObject[keyof typeof myObject]) => {
    console.log(param)
}
oFunction(myObject.a);
// "A"

결국 enum은 상수 집합 선언에 있어서 TS를 사용할 때는 가장 적절한 편의성을 제공해준다고 할 수 있겠다. 나 같은 경우 인라인으로 string값들이 들어가야 하는 경우 왠만하면 enum을 통해 상수 선언을 해주고 이를 활용한다.


지금부터 나오는 타입들은 JS에는 없는, TS에서만 지원하는 타입들이다.

any

말 그대로 any. 어떤 타입, 어떤 값이던간에 TSC가 상관하지 않겠다는 의미의 타입이다. TS를 공부하면서 가장 조심해야 하는 녀석이라고 할 수 있다. 빠르게 테스트용 컴포넌트를 짤 때나 테스트용 코드 등을 작성할 때나 사용하는 것이 좋고, 왠만하면 사용하지 않는 것이 이롭다. 나는 옵셔널 체이닝(’?’)과 any를 남발하는 것은 TS를 쓰는 이유를 없애는 행위라고 생각한다.

let myVar: any = 'hi';

myVar = 1;
myVar = { a: "A" };
console.log(myVar.length);
// undefined

위처럼 any로 선언된 변수는 어떤 값을 할당해도 TSC가 에러를 뱉지 않는다. myVar은 Object로 선언되어 Object의 프로토타입에 length 멤버가 정의되어있지 않기 때문에 length의 반환값이 아닌 undefined를 출력하는 것을 확인할 수 있다.


unknown

any와 유사한 unknown 타입의 변수는 어떤 값이던 가질 수 있지만, 타입에 대한 확인을 TSC에 요구한다. any 타입의 경우에는 타입 안정성을 해칠 수 있지만, unknown 타입의 값에 대해서는 명시적인 타입 체크가 요구되기 때문에 타입 안정성을 확보하기 위해서 사용된다.

let myVar: unknown = 'hi';

myVar = 1;
myVar = { a: "A" };

위처럼 unknown 타입으로 선언되면 any와 마찬가지로 값을 할당할 때는 어떤 타입의 값이 할당되어도 문제가 되지 않는다. 하지만 아래와 같이 특정 멤버를 호출하거나, 함수의 파라미터나 반환 등으로 unknown 타입의 변수를 넣어줄 경우 타입 체크를 요구하는 에러가 출력된다.

let myVar: unknown = 'hi'

console.log(myVar.length);
// 'myVar' is of type 'unknown'.

아래와 같이 변수가 어떤 타입을 갖는지 type narrow를 통해 해당 에러를 해소할 수 있다. typeof의 반환값

let myVar: unknown = 'hi'

if (typeof myVar === 'string') {
    console.log(myVar.length);
    // 2
}

JS 베이스의 코드를 TS로 마이그레이션할 때 타입 선언에 어려움이 있을 경우 any로 선언하는 경우가 많은데, 이 때 unknown 타입을 사용하게 되면 타입에 대한 안정성은 확보할 수 있다. 물론 작성에는 번거로움이 있을테지만 말이다.


void

void 타입은 함수의 반환 타입에만 사용되는 특수한 타입으로, 반환값이 없음을 의미하는 타입이다. 어떻게 보면 굳이 해당 타입을 분류하는 의미가 없어 보이지만, 명시적으로 함수의 반환값이 없음을 보여줌으로써 함수의 반환 할당 구문 등을 구성할 수 없음을 알려주기 때문에 타입 에러를 피할 수 있게 도와준다.

반환형이 void로 설정된 함수는 return 구문을 작성하지 않아도 문제가 되지 않는다. 콘솔로 void 반환형의 함수가 어떤 값을 반환하는지 확인해보면, undefined를 반환하는 것을 확인할 수 있다. TS상에서의 타입은 void이다. 특정 변수에 할당까지는 가능할 수 있어도, 할당된 변수는 undefined값을 가지기 때문에 아무 의미가 없다.

type functionType = () => void;

const myFunc:functionType = () => {
  console.log('hi')
}

const myVar = myFunc();
// "hi"

console.log(myVar);
// undefined

myVar + 1;
// Operator '+' cannot be applied to types 'void' and 'number'.

void 타입은 undefined 타입의 부분집합이라고 보면 좋다. 함수의 반환형으로 undefined를 작성하면, void 타입으로 작성했을 때와 마찬가지로 작동한다. 심지어 void 타입의 변수에는 undefined를 할당할 수 있다. 사실상 void와 undefined의 코드레벨에서의 기능은 일치한다는 건데, TS의 설계상 반환이 없는 함수의 반환형을 위한 특수한 플레이트라고 생각하면 된다.

const myFunc = ():undefined => {
  console.log('hi')
}

myFunc();
// "hi"

const myVar:void = undefined;

never

never 타입은 TS에서 타입 유니온 등으로 선언된 타입에 대해 각 타입체크 구문에서 모든 케이스가 필터링되고, 도달할 수 없는 코드블럭 내에서, 가질 수 없는 타입을 가진 변수의 타입을 의미한다. primitive type, object 등을 제외하고 JS에서는 가질 수 있는 변수의 타입이 없기 때문에, typeof를 통한 타입 체크 구문을 통해 분기처리되지 않은 경우는 JS의 타입 예외의 경우에 속한다고 볼 수 있다.

type myType = 'string' | 'number';

const myFunc = (param:myType) => {
  if(typeof param === 'string'){
    // ...
  }else if(typeof param === 'number'){
    // ...
  }else{
    param // 조건문을 모두 넘기고 여기까지 오면 TS는 param 변수의 타입을 never로 간주한다.
  }
}

never 타입을 코드에 직접 사용하는 경우는, 항상 에러를 던지거나 반환이 존재하지 않는(void와는 다르다.), 항상 오류 발생 케이스를 갖는 함수의 반환 타입을 지정할 때 밖에 없다고 봐도 무방하다.

정확하게는 항상 에러를 던진다는 것은 항상 반환이 되지 않는 함수를 의미하기 때문에, 항상 값을 반환하지 않는 함수의 반환형으로 사용한다고 할 수 있다.

const throwErr = ():never => {
  throw new Error()
}

여기까지 TS에서 지원하는 다양한 타입들을 알아봤다. 사실 각 타입들에 대해 깊게 파고들려면 끝도 없지만, 우리는 당장에 각 타입들이 어떤 역할을 하는 지, 어떤 상황에 사용되어야하는 지 공부하는 것을 우선으로 두었기 때문에 이 정도까지만 해두자.

다른 타입은 없냐고 물어보신다면, JS 단의 로우 레벨에서의 타입은 이제 없다고 봐도 무방하다. 물론 모듈화되어 활용될 수 있는 utility types도 남아있지만, 이는 옵셔널한 내용들이니 시리즈의 부록 쯤에서 다루게 될 것 같다.

다음 포스팅부터는 앞서 계속 나왔던 제너릭 타입과, 옵셔널 체이닝, type narrow, type guard 등 TS에서 사용되는 특수한 문법들에 대해서 알아보도록 하자.

profile
DIVIDE AND CONQUER

0개의 댓글