방구석에 놔둔 개발 노트

1년차 웹 / 앱 프론트엔드 엔지니어의 좌충우돌 얼렁뚱땅 앞뒤짱구 생존기

2023-01-10: TypeScript 학습 (1)

이제야 시작해보는 TypeScript

말로만 듣던 TypeScript 학습을 드디어 시작해보려고 한다. 언젠가 들어야지 해놓고서는 계속 방치해두고 있었으나, JavaScript 클론 코딩 챌린지를 하나 완수한 것도 있고 이번주부터 천천히 개인 프로젝트를 진행해봐야겠다고 생각한 것이 있어서 이왕 할 거라면 좀 더 다양하게 공부해봤으면 하는 생각이 들었다.

그런 의미에서 개인 프로젝트에서는 많은 욕심을 내보고자 했다.

  • TypeScript를 써보자.
  • 새로운 라이브러리를 써보자.
  • 단위 테스트를 해보자.
  • StoryBook이나 Jest를 활용해보자.

그 중 학습에 관한 첫 단계로서, 저 멀리 방치해두고 있던 TypeScript를 이제서야 공부해보려 한다. 공부할 내용들은 많겠지만, 우선은 Udemy 강의에서 React와 Next.js, TypeScript를 결합한 강의가 있어 해당 강의에 있는 TypeScript 내용만을 우선적으로 들으면서 학습 내용을 정리해가고자 한다.

공부하는 내용들은 Udemy의 【한글자막】 React 완벽 가이드 with Redux, Next.js, TypeScript의 내용을 따르고 있으며, 부족하다고 느끼는 부분들은 별도로 조사하고 정리하는 형태가 될 것이다.

목차

1️⃣ TypeScript란?

2️⃣ TypeScirpt의 타입 정의 - 원시 타입

3️⃣ TypeScirpt의 타입 정의 - 참조 타입

4️⃣ 타입 추론 (Type Inference)

5️⃣ Union Type

6️⃣ 타입 별칭 (Type Alias)

7️⃣ 함수의 타입 정의 - 매개 변수와 반환값

8️⃣ 제네릭 (Generic)

9️⃣ Tuple과 Enum, 그리고 Never

1️⃣ TypeScirpt란?

TypeScript의 문서를 보면, TypeScript는 자신을 이렇게 소개하고 있다.

TypeScript는 타입 문법과 함께하는 JavaScript이다. TypeScript: 정적 타입 체커 JavaScript의 타입 확장(superset)

여기서, 확장(superset)이란 특정 언어의 모든 기능을 포함하면서, 다른 기능까지 포함하도록 향상 및 확장된 것을 가리킨다.

이렇게 TypeScript는 JavaScript를 기반으로 하되, JavaScript의 타입을 체크하는 정적 타입 언어이다.

좀 더 쉽게 말하자면, JavaScript를 정적 타입 언어로 바꾼 것이 TypeScript이다.

TypeScript를 사용하면 JavaScript에서 동적 타입으로 인해 발생할 수 있는 문제들을 정적 타입으로 사전에 정의함으로서 타입 에러를 방지할 수 있으며, 타입의 안정성을 통해 생산성을 높일 수 있다는 장점이 있다.

참고로, TypeScript 코드는 브라우저에서 실행되지 않는다. 브라우저에서 TypeScript를 지원해주지 않으므로, TypeScript로 작성한 코드를 사용하기 위해서는 TypeScript로 코드를 작성한 뒤, JavaScript의 형태로 컴파일해줘야 한다는 걸 기억해두자.

2️⃣ TypeScirpt의 타입 정의 - 원시 타입(Primitive Type)

TypeScript의 데이터 타입은 JavaScript의 데이터 타입과 거의 동일하며, 크게 아래와 같이 분류한다.

// 원시 타입: string, number, boolean, null, undefined
// 참조 타입: array, object, void, tuple, enum
// 그 외: any

여기서는 TypeScript의 대표적인 원시 타입인 number, string, boolean을 보자.

우선, 타입 선언은 대문자가 아닌 소문자로 시작한다. 대문자로 시작해버리면 JavaScript의 클래스 객체와 겹칠 수 있다. 타입을 선언하고 싶을 때는, 변수명 뒤에 :데이터 타입을 붙여 변수의 타입을 정의하면 된다.

// number
let age: number;
age = 12;

// string
let userName: string;
userName = "John";

// boolean
let isInstructor: boolean;
isInstructor = true;

물론, JavaScript에 있는 특별한 원시 타입인 nullundefined도 타입으로 지정할 수 있다.

let house: null;
let address: undefined;
// 다만 null과 undefined는 이런 식으로 타입을 정의하진 않는다.
// 어디까지나 예시로 지정할 수 있다는 걸 알려주기 위함이니 참고만 하자.

3️⃣ TypeScirpt의 타입 정의 - 참조 타입(Reference Type)

여기서는 TypeScript의 대표적인 참조 타입인 arrayobject를 다뤄보겠다.

우선 배열(array)을 정의하는 방법은 기존의 원시 타입과 동일하게 정의하되, 뒤에 []를 붙인다는 특징이 있다.

let hobbies: string[]; //'문자열 배열'임을 명시하는 방법
hobbies = ["Sports", "Cooking"];

let ranks: number[]; //'숫자 배열'임을 명시하는 방법
ranks = [1, 52];

객체(object)를 정의하는 방법은 좀 다른데, 아래와 같이 정의한다.

우선, 변수에 객체(object) 타입을 선언할 때는 변수명 뒤에 :{}를 사용한다. 중요한 것은 :{}를 쓴다고 해서 객체가 생성되는 게 아니라 객체라는 타입이 정의된 것 뿐이다. (객체를 생성하는 건 JavaScript 문법과 동일하게 ={}이라는 점을 기억하자)

만일, 객체 내부에 있는 값들에 타입을 맞추고 싶다면, 각 key에 원하는 타입을 정의해주도록 하자.

let person: {
    // :{}를 통해 변수 person은 객체 타입임을 정의한다.
  name: string; // name key의 value는 문자열만 가능하도록 정의했다.
  age: number; // age key의 value는 숫자만 가능하도록 정의했다.
};

person = {
  name: "John",
  age: 32,
};
// name은 문자열, age는 숫자이므로 정의한 내용과 동일하기에 person에 대입해도 문제가 없다.

person = {
  isEmployee: true
}
// person 객체에는 isEmployee라는 key를 정의한 적이 없다.
// 따라서 이 경우에는 에러가 발생한다.

경우에 따라서는 객체가 여러 개 있는 배열을 만들어야할 때가 있다. 이 경우, 어떻게 하면 그런 배열을 만들 수 있을까?

아래의 예시 코드를 보자.

let people: {
  name: string;
  age: number;
}[];

위와 같이 객체 타입을 선언한 후에, 뒤에 []를 붙여주면 변수 people은 { 문자열을 받는 name value와 숫자를 받는 age value } 형태만 받을 수 있는 배열로 정의된다.

이처럼, 객체 타입 뒤에 []를 붙임으로서 객체 타입을 정의한 배열을 정의할 수 있다.

4️⃣ 타입 추론 (Type Inference)

타입스크립트는 최대한 많이 타입을 체크하려고 노력한다. 명시적으로 타입을 표기하지 않았더라도, 어떤 타입을 어디에 정의내려야 할지 알아내려고 한다.

아래의 코드를 보자.

let course = "React - The Complete Guide";

course = 12341; //'number' 형식은 'string' 형식에 할당할 수 없습니다.

위의 코드에서 변수 cource의 값에 숫자인 12341을 넣어 초기화하려고 하나, 에러를 발생시킨다.

TypeScript에서는 변수를 만들고 값을 초기화하면, 초기화한 값의 자료형을 확인하여 해당 변수의 타입을 정의하려 한다.

이것이 TypeScript의 특징 중 하나인 '타입 추론'이다. TypeScript에서는 이러한 타입 추론 덕분에 타입 선언을 하지 않아도 타입이 정의되므로, 코드 낭비를 줄일 수 있는 장점이 있다.

5️⃣ Union Type

경우에 따라서는, 하나의 변수에 두 개 이상의 타입을 정의해야 할 때가 있다. 이 때는 기존과 동일하게 : 뒤에 타입을 정리하되, 추가로 정의하고자 하는 타입을 |를 이용해 이어서 정의해주면 된다.

이를 유니온 타입(Union Type)이라고 부른다.

let lecture: string | number = "React - The Complete Guide";

6️⃣ 타입 별칭 (Type Alias)

타입 별칭이란, 동일한 타입을 반복적으로 정의내리는 행위를 줄일 수 있는 효과적인 방법이다.

타입 별칭을 이용하면 새로운 타입을 정의할 뿐만 아니라, 내부에 다양한 요소들까지도 쉽게 타입을 정의내릴 수 있다.

타입 별칭을 사용하고 싶다면 type 연산자를 사용한다.

type Person = {
  name: string;
  age: number;
};

type 연산자로 새로운 타입을 생성할 때, 기존에 쓰던 :d이 아닌, =를 통해 타입을 생성한다.

이렇게 타입 별칭을 통해 생성한 Person이라는 타입은 기존에 타입 선언과 동일하게 변수에 붙여 정의할 수 있다.

이렇게 정의내린 타입은 후에 변수에 어떠한 값을 넣을 때에 정의한 Person과 동일한 형태를 가지지 않으면 에러를 발생시킨다.

let james: Person;
// 타입 별칭을 이용해 새로 만든 타입 Person을 변수 james의 타입으로 정의한다.

james = {
  name: "james",
  age: 32,
  // address: 'New York'
};
// 변수 james는 타입 Person으로 정의되어 있으므로, 
// Person으로 정의된 형식과 맞지 않으면 에러가 발생한다.

참고로, 타입 별칭을 이용해 생성한 새로운 타입은 해당 타입을 갖춘 배열로도 정의내릴 수 있다.

이 방법을 이용해 배열에 요소가 들어갈 때마다, 반복적으로 정의를 내릴 필요가 없어지므로 중복이 줄어들어 코드가 깔끔해지는 효과가 있다.

let peopleList: Person[];
// 타입 Person을 이용해 변수 peopleList를 타입 Person을 갖춘 객체들의 배열로 정의할 수도 있다.

7️⃣ 함수의 타입 정의 - 매개 변수와 반환값

TypeScript는 함수의 매개변수에 타입을 지정해줄 수 있다.

function add(a: number, b: number) {
  return a + b;
}

매개변수에 타입을 지정해줬다면, 반환값은 반환되는 결과에 따라 자신의 타입을 추론하게 된다. 물론, 타입 추론과 별개로 함수의 반환값 자체도 아래처럼 정의할 수 있다.

function add2(a: number, b: number): number | string {
  return a + b;
}

함수의 타입에서 중요한 것은 매개변수의 타입 뿐만 아니라, 반환 값의 타입도 생각해야 한다. 함수에는 입력(매개 변수)만 있는 것이 아니라 출력인 반환(return)도 존재한다는 것을 잊지 말자.

때때로, 어떤 함수에서는 return을 쓰지 않는 경우도 있다. 이렇게 return이 쓰이지 않는 함수는, void라는 타입을 반환값에 정의내릴 수 있다.

function printConsoleLog(value: any): void {
  console.log(value);
}

voidnull이나 undefined와 비슷하지만 항상 함수와 결합해서 사용한다는 특징이 있다. 함수에 void 타입이 보인다면 해당 함수는 반환값이 없다고 정의했다는 것을 기억해두자.

8️⃣ 제네릭 (Generic)

아래의 함수는 여러가지의 값을 받아오게 하기 위해서 매개변수의 타입을 any로 설정했다.

function insertAtBeginning(array: any[], value: any) {
  const newArray = [value, ...array];
  return newArray;
}

이 함수를 이용하면 기존 배열을 해치지 않으면서, 매개변수로 들어온 값을 통해 새로운 배열을 만들어 낼 수 있다.

그럼 이제 이 함수를 이용해 새로운 변수에 배열을 하나 만들어보려고 한다. 해당 변수는 어떤 타입을 가지게 될까? 아래의 코드를 보자.

const demoArray = [1, 2, 3];

const updatedArray = insertAtBeginning(demoArray, -1);

위의 함수를 변수 updatedArray에 이용해 값을 반환하면 [-1, 1, 2, 3]을 받아오게 된다. 여기서 반환된 값은 숫자 배열인데, 그렇다면 updatedArray의 타입도 숫자 배열일까?

아니다, updatedArray의 배열의 타입은 any로 추론될 것이다.

함수 insertAtBeginning의 매개변수들은 any로 정의내려짐에 따라 반환값 역시 타입 추론에 따라 any로 타입이 정의된다.

이로 인해 변수 updatedArray가 받아오는 배열도, 반환값인 배열의 타입이 any이므로 똑같이 any로 정의내려진다.

any 타입은 사실상 JavaScript의 동적 언어 타입을 그대로 적용하는 타입이다. 따라서 TypeScript에서 지원해주는 타입 에러 체크가 먹히지 않는다. 아래의 코드를 보자.

updatedArray[0].split("");

updatedArray의 반환값이 [-1, 1, 2, 3]인 숫자로 이루어진 배열이라는 것을 우리는 알고 있지만 위의 코드를 호출할 때, TypeScript의 타입 에러는 발생하지 않는다.

변수 updatedArray의 타입은 아까도 말했듯이 any이므로 TypeScript는 어떠한 타입이든 받을 수 있는 거라고 생각하여 에러 체크를 넘겨버린다.

그 결과 메소드인 split()이 호출된 뒤에, 이 내용은 Uncaught TypeError를 내뱉게 된다. (split() 메소드는 문자열에서 쓰인다.)

그렇다면 위의 updatedArrayany 타입이 아닌 정적 타입으로 추론하여 정의되게 하려면 어떻게 해야 할까?

바로 이럴 때에 쓰는 것이 제네릭(Generic)이다. 앞에서 만들었던 함수 insertAtBeginning를 본딴 새로운 함수 insertAtBeginningNew를 보자.

function insertAtBeginningNew<T>(array: T[], value: T) {
  const newArray = [value, ...array];
  return newArray;
}

제네릭은 함수에서만 정의되는 타입이다. (추후에 클래스에서도 정의가 가능하다고 들었는데.. 이건 확실하면 수정하도록 하겠다.)

제네릭 타입을 정의하려면 우선 함수명 뒤에 <>괄호를 넣어 제네릭을 사용하도록 정의내린다. 그 후, 괄호 속에 있는 값을 제네릭의 타입명으로 정의한다.

위의 함수 insertAtBeginningNew에서는 앞서 설명한 내용에 따라 T라는 값이 제네릭 타입임을 알 수 있다. 이렇게 정의내린 타입은 함수의 매개 변수인 arrayvalue에서도 정의내릴 수 있다.

그러면, 제네릭을 이용해 만든 함수를 가지고 아래의 변수 updatedNewArray에 함수를 호출해 반환하면 어떤 타입이 나오게 될까?

const updatedNewArray = insertAtBeginningNew(demoArray, -1); // number[]

결론적으로, updatedNewArray의 타입은 숫자 배열로 정의된다.

위에서 함수 insertAtBeginnningNew는 제네릭을 통해서 정의한다고 알려줬다. 또, 매개변수인 arrayvalue에도 ‘제네릭으로 정의했다’고 정의했는데, 이는 다시 말해 두 매개변수는 서로 '같은 타입'이어야 한다고 정의한 것과 동일한 의미이다.

이 때 함수에 들어온 매개변수의 값들을 보면 array는 숫자 배열인 demoArray였고, value는 숫자인 -1이므로 변수 updatedNewArray의 타입은 숫자 배열로 정의된다.

그렇다면, 만약 서로 다른 타입을 넣을 경우, 어떻게 될까?

const updatedNewArray2 = insertAtBeginningNew(demoArray, "야호");
// 에러: 'string' 형식의 인수는 'number' 형식의 매개 변수에 할당될 수 없습니다.

위와 같이 에러가 발생한다. array는 숫자 배열이지만, value는 문자열이므로 타입이 서로 달라 에러가 발생한다.

자, 그러면 이제 마지막으로 array에는 문자열 배열을, value에는 문자열을 넣는다면 어떻게 될까?

const updatedNewArray3 = insertAtBeginningNew(["a", "b", "c"], "z");

arrayvalue 모두 서로 같은 타입인 문자열이므로 에러가 발생하지 않는다.

이렇게 제네릭은 함수에 타입 안정성과 유연함을 줄 수 있는 큰 장점이 있다. 또한 해당 함수를 반환하여 받는 변수에도 정확한 타입을 지정해줄 수 있으므로, 필요에 따라 적극적으로 활용해보도록 하자.

P.S. 제네릭을 한 트친분께서 다음과 같이 정의해주시기도 하셨는데, 맞는 말이라고 생각해서 추가로 작성해봤다.

  • 제네릭은 타입을 인자로 받아 타입을 리턴하는 함수이다.

⚠️ number[]와 Array가 같다고?

강의를 듣다가 이해가 가지 않았던 부분이 하나 있었는데, 바로 숫자 배열을 선언할 때에 위에서 배운 number[] 외에도 Array<number>도 같은 의미로 사용할 수 있다는 것이었다.

다음의 코드를 보자.

let numbers1: number[] = [1, 2, 3];
let numbers2: Array<number> = [1, 2, 3];

이 둘은 같은 숫자 배열을 타입으로 정의한다고 한다.

근데 numbers2에 있는 Array는 클래스가 아니던가? 함수에서만 사용할 수 있는 제네릭을 Array는 어떻게 사용하고 있는 걸까? 그래서 어떻게 Array<number>number[]과 동일한 숫자 배열로 정의한다고 내릴 수 있는 걸까?

답은 바로 numbers2 에서 쓰이는 Array는 클래스가 아닌 생성자 함수이기 때문이다. Array생성자 함수이므로 제네릭을 쓰는 것은 문제가 되지 않으며, 제네릭 타입을 number로 지정해놓은 것은 내부에서 해당 타입을 number로 지정했기 때문이다.

따라서, 두 변수 number1number2는 같은 숫자 배열을 정의할 수 있다고 볼 수 있다.

⚠️ 그러면 이건 Array 생성자 함수에만 해당하는 일일까?

찾아본 바로는 YES이다. 생성자 함수와 class, prototype를 공부해봤으면 알겠지만 JavaScript의 생성자 함수에는 다양한 함수들이 존재한다.

Number, String, Array, Boolean, Object 등이 존재하는데 이 중에서 위처럼 제네릭을 사용하고 있는 생성자 함수는 Array 밖에 없는 것으로 보인다.

interface Number {
    /**
     * Converts a number to a string by using the current or specified locale.
     * @param locales A locale string, array of locale strings, Intl.Locale object, or array of Intl.Locale objects that contain one or more language or locale tags. If you include more than one locale string, list them in descending order of priority so that the first entry is the preferred locale. If you omit this parameter, the default locale of the JavaScript runtime is used.
     * @param options An object that contains one or more properties that specify comparison options.
     */
    toLocaleString(locales?: Intl.LocalesArgument, options?: Intl.NumberFormatOptions): string;
}

interface String {
    /**
     * Matches a string or an object that supports being matched against, and returns an array
     * containing the results of that search, or null if no matches are found.
     * @param matcher An object that supports being matched against.
     */
    match(matcher: { [Symbol.match](string: string): RegExpMatchArray | null; }): RegExpMatchArray | null;

    /**
     * Passes a string and {@linkcode replaceValue} to the `[Symbol.replace]` method on {@linkcode searchValue}. This method is expected to implement its own replacement algorithm.
     * @param searchValue An object that supports searching for and replacing matches within a string.
     * @param replaceValue The replacement text.
     */
    replace(searchValue: { [Symbol.replace](string: string, replaceValue: string): string; }, replaceValue: string): string;
    .
    .
    .
}

interface Boolean {
    /** Returns the primitive value of the specified object. */
    valueOf(): boolean;
}

interface Object {
    /** The initial value of Object.prototype.constructor is the standard built-in Object constructor. */
    constructor: Function;

    /** Returns a string representation of an object. */
    toString(): string;

    /** Returns a date converted to a string using the current locale. */
    toLocaleString(): string;
    .
    .
    .
}

생성자 함수들인 Number, String, Boolean, Object에도 제네릭이 선언되어 있지 않다. 그러나 Array에는 제네릭이 선언되어 있는 것을 볼 수 있다.

interface Array**<T>** {
    /**
     * Gets or sets the length of the array. This is a number one higher than the highest index in the array.
     */
    length: number;
    /**
     * Returns a string representation of an array.
     */
    toString(): string;
    /**
     * Returns a string representation of an array. The elements are converted to string using their toLocaleString methods.
     */
    toLocaleString(): string;
    /**
     * Removes the last element from an array and returns it.
     * If the array is empty, undefined is returned and the array is not modified.
     */
    pop(): T | undefined;
    /**
     * Appends new elements to the end of an array, and returns the new length of the array.
     * @param items New elements to add to the array.
     */
    push(...items: T[]): number;
    /**
     * Combines two or more arrays.
     * This method returns a new array without modifying any existing arrays.
     * @param items Additional arrays and/or items to add to the end of the array.
     */
  .
  .
  .
}

9️⃣ Tuple과 Enum, 그리고 Never

위에서 다양한 타입들을 다뤄봤지만, 아직 다뤄보지 않은 데이터 타입이 몇 가지 있다. 여기서는 TupleEnum, 그리고 Never에 대해 정리해보고자 한다.

우선 Tuple참조 타입인 array에서만 사용 가능한 타입이다. Tuple 타입을 사용하려면 타입 별칭처럼 type 연산자를 선언해 정의해야한다.

type beer = [string, number]; // beer는 tuple 타입이다.
const gooseIsland:beer = ["Goose IPA", 5.9];
const magpie:beer = ["MAGPIE PORTER", "MAGPIE KÖLSCH"]; // error
const gorilla:beer = [7.6, 6.2]; //error
const Turmbrau:beer = ["Helles", 4.8, "Roggen", 4.4]; // error

Tuple 타입은 위와 같이 정의된 배열의 형태를 갖춰야만 한다. 정의된 값 외의 다른 값을 더 추가하거나, 모자르게 해도 에러를 출력한다.

Tuple 타입으로 된 배열은 정해진 위치에 맞는 타입이 정의된 형태라 순서가 고정되어 있으며, 이로 인해 각 배열의 위치를 바꾸는 것도 불가하다. 단, 정해진 위치의 타입만 지킨다면 배열 내부의 값을 변경하는 것은 가능하다.

Enum은… (추가 예정입니다.)

Never는 절대 발생하지 않을 값을 의미한다.

Never 타입으로 정의한 변수나 함수는 어떠한 타입도 적용할 수 없다.

let alphabet: never
alphabet = "abc"; // 에러: 'string' 형식은 'never' 형식에 할당할 수 없습니다.
alphabet = 1415; // 에러: 'number' 형식은 'never' 형식에 할당할 수 없습니다.
alphabet = true; // 에러: 'boolean' 형식은 'never' 형식에 할당할 수 없습니다.

let zeroArray: [] = []; // 타입을 빈 배열로 하면 어떠한 요소도 받지 못한다.
zeroArray.push("alphabet")
// 에러: 'string' 형식의 인수는 'never' 형식의 매개변수에 할당될 수 없습니다.

또한 함수가 Never 타입일 경우, 해당 함수는 끝에 도달하지 않는다는 의미를 가진다. 이 뜻이 많이 생소했는데 쉽게 풀이하자면 아래와 같다.

  • 일반적인 함수는 retrun을 가진다.
  • void 타입의 함수는 return이 없다는 의미로서, void를 반환한다.

위의 두 내용을 기준으로 Never 타입을 풀이하자면 아래와 같다.

  • Never 타입의 함수는 return에 도달하지 않는다.
    • 끝에 도달하지 않는다는 것은 바로 이런 의미이다.

예시 코드를 보자.

function errorPractice(): never{
  throw console.log("exception occur!");
    return; //에러: 'undefined' 형식은 'never' 형식에 할당할 수 없습니다.
}

위의 함수 errorPractice를 적용하면, return으로 들어오지 않고 에러 처리에 따라 throw로 들어가게 된다.

이처럼 끝이 나지 않는다는 의미는 ‘절대로 도달할 수 없는 분기’를 의미하는 것이므로 위의 같은 코드처럼 return에 도달하지 않게 되는 사례가 Never 타입의 예시가 될 수 있다.

이런 Never 타입을 활용함으로서 조건에 맞지 않는 경우일 때에, Never 타입의 함수를 이용해 에러를 출력시키는 용도 등으로 활용해볼 수도 있다. (자세한 건 아래 참고 자료의 타입스크립트의 Never 타입 완벽 가이드를 한 번 읽어보자.)

📁 참고 자료

기본 타입 | 타입스크립트 핸드북

한눈에 보는 타입스크립트(updated)

타입스크립트의 Never 타입 완벽 가이드