내용 정리 Typescript - 타입스크립트의 문법 2.

이유승·2023년 8월 17일
0

내용 정리

목록 보기
25/31
post-thumbnail

1. 이넘Enum

숫자형 이넘

특정 값들의 집합을 의미하는 자료형. 드랍다운 같이 미리 정해져있는 목록의 값을 지정하는데 사용된다.

enum Avengers {
  Capt,
  Ironman,
  Hulk,
}

const myHero = Avengers.Capt;

enum의 경우 초기값을 따로 부여하지 않으면 숫자형 이넘을 선언했다고 간주한다. 숫자형 이넘의 기본 초기값은 0, 사용자가 다른 값을 명시적으로 대입해주면 그 값이 초기값으로 사용된다. 위 예시에서 myHero 변수는 0의 값을 갖는다.

문자열형 이넘

enum Direction {
  Up = "UP",
  Down = "DOWN",
  Left = "LEFT",
  Right = "RIGHT",
}

Direction.Up; // "UP"

타입스크립트의 이넘은 주로 상수를 선언할 때 사용된다. 코드 여러 곳에서 공통적으로 사용되는 숫자나 문자열이 있을 때, 모든 곳에 일일이 숫자나 문자열을 집어넣게 되면 코드의 유지보수성이 하락하게 된다. 하나를 수정하면 다른 모든 곳에서도 똑같이 수정해 주어야하기 때문. 그런데 이넘을 사용하게 되면 최상단 Enum 선언문에서만 수정해주면, 나머지 곳에서는 알아서 수정된 값으로 반영된다.



2. 제네릭

C#, Java 등의 언어에서 재사용성이 높은 컴포넌트를 만들 때 자주 활용되는 개념. 타입스크립트에서는 한 가지 타입보다 여러 가지 타입에서 동작하는 컴포넌트를 생성하는데 사용된다. 제네릭이란 타입을 마치 함수의 파라미터처럼 사용하는 것을 의미한다.

제네릭을 사용하면 함수를 호출 할 때, 사용되는 데이터의 타입까지 같이 넘겨줄 수 있다.

function logText1<Type>(text: Type): Type {
  console.log(text);
  return text;
}

// 이 경우 제네릭을 사용하지 않았지만, 타입스크립트가 문자열 데이터임을 감지하고 제네릭으로 사용해준다.
logText1('10');

// 물론 이렇게 제네릭을 명시적으로 사용하는 것이 정석적인 방법.
logText1<String>('10');

제네릭은 왜 사용하는가?

타입스크립트는 타입이 정해져있기 때문에 다양한 데이터 타입을 동시에 사용할 수가 없다. 그렇다고 any 타입을 남발하게 되면 타입스크립트의 사용 이유가 없어지는 셈.

function logText(text: string): string {
  return text;
}

function logNumber(text: number): number {
  return text;
}

이 두 함수는 완전히 동일한 기능을 수행하지만, 타입에 의해 어쩔 수 없이 2개로 나누어져 있다. 유니온 타입을 사용하면 이런 문제를 다소 줄일 수 있지만.. 결국 사용할 수 있는 타입이 제한된다는 것은 마찬가지이다. 다른 데이터가 추가된다면 똑같이 문제가 발생한다.

function logText3(text: string | number): string | number {
  return text;
}

더구나 인터페이스 사용시 어느 한 쪽에 존재하지 않는 요소는 접근이 불가능하고, 프로토타입으로 상속받은 함수를 제대로 사용할 수 없는 경우도 발생할 수 있다. 그래서 그냥 제네릭 타입을 사용하는게 여러모로 편하다.

제네릭 타입의 사용

function logText4<Type>(text: Type): Type {
  console.log(text);
  return text;
}

const num = logText4<Number>(10);
const str = logText4<String>('10');
num.toString();
str.concat();

타입 자체를 인자로 보내면서 어떤 데이터 타입도 자유자재로 사용이 가능하고, 프로토타입으로 상속받은 함수도 아무 문제 없이 사용이 된다.

제네릭 타입과 인터페이스

interface Developer<T> {
  name: string;
  age: T;
}

const tony: Developer<number> = { name: 'tony', age: 100 };

Developer 인터페이스를 선언할 때 사용한 제네릭의 타입이 인터페이스로 넘어가서 age의 type을 결정하는데 사용된다.

제네릭에 의한 타입 제한

function getNumberAndArray<T>(value: T): T {
  return value.length; // 에러!
}

getNumberAndArray에서 인자의 타입은 구체적으로 정의되지 않았다. 따라서 value의 length를 호출하는 코드는 에러가 발생하게 된다. value의 타입이 무엇인지 모르는 상태에서 length가 존재하는지 확실하지 않기 때문이다.

이 문제를 해결하기 위해서는 제네릭 타입의 범위를 조금 더 좁히는 방법이 있다. 배열은 기본적으로 length가 존재하므로 에러가 발생되지 않는다. 타입스크립트에게 타입을 추론할 힌트를 더 준다고 생각하면 이해가 빠르다.

function getNumberAndArray<T>(value: T[]): T[] {
  return value.length;
}

이 경우, value이 배열 타입이라는 정보가 주어지기 때문에 value는 배열이라고 간주되어 에러가 발생되지 않는다.

제네릭에 의한 타입 제한 - 상속

interface LengthWise {
  length: number;
}

function logText<T extends LengthWise>(text: T): T {
  return text.length;
}

logText('10'); // 에러!

logText 함수는 LengthWise 인터페이스에 있는 length를 사용할 수 있다. 그런데 숫자 10을 인자로 사용하면 에러가 발생된다. 숫자 데이터에는 length 함수가 존재하지 않기 때문이다. 이 경우에는 length 라는 변수를 만들어 제공해주면 에러가 발생하지 않는다.

logText({length: 10});

제네릭에 의한 타입 제한 - keyof

interface ShoppingItems {
  name: string;
  price: number;
  address: string;
  stock: number;
}

function getAllowedOptions<T extends keyof ShoppingItems>(option: T): T {

  if (option === 'name' || option === 'address') {
    console.log('option type is string');
    return option;
  }
  if (option === 'price' || option === 'stock') {
    console.log('option type is number');
    return option;
  }

}

getAllowedOptions('name');
getAllowedOptions('price');
getAllowedOptions('address');
getAllowedOptions('stock');

사용될 수 있는 타입을 제한한다. 인터페이스에서 정의된 name, price, address, stock만 사용할 수 있다. 인터페이스에 정의하지 않는 것은 사용이 불가능하다.

getAllowedOptions('nothing'); // 에러!



3. 타입 추론Type Inference

타입 추론이란 타입스크립트가 코드를 해석해 나가는 과정을 뜻한다.

  let x = 3;
  

여기서 타입스크립트는 개발자가 타입을 따로 지정하지 않더라도, 일단 x를 number로 간주한다. 이렇게 변수를 선언하거나 초기화 할 때, 변수, 속성, 인자의 기본 값, 함수의 반환 값 등을 설정할 때 타입 추론이 일어난다.

인터페이스와 제네릭을 이용한 타입 추론 방식

interface Dropdown<Type> {
  value: Type;
  title: string;
}

제네릭의 값을 타입스크립트 내부적으로 추론하여, 변수에 필요한 속성들을 보장해준다.

복잡한 구조에서의 타입 추론 방식

interface DetailedDropdown<Type> extends Dropdown<Type> {
  description: string;
  tag: Type;
}

var detailItems: DetailedDropdown<number> = {
  value: 123,
  title: 'a',
  description: 'b',
  tag: 456
}

detailItems는 DetailedDropdown 인터페이스를 사용하면서 제네릭으로 타입을 넘겨주었다. 이 타입 정보는 DetailedDropdown 인터페이스가 받아서 상속을 내려보내고 있는 Dropdown으로 넘어가서 사용된다.

타입스크립트 랭귀지 서버Typescript Language Server

타입 추론을 실행하는 주체. 타입스크립트 랭귀지 서버는 타입에 맞는 변수들이 사용되었는지 검사하며 코드가 자동완성 되거나, 사용이 가능한 함수 등을 미리 보여주는 IntelliSense의 동작을 보장한다.

타입스크립트 랭귀지 서버는 타입스크립트를 설치했을 때 라이브러리 폴더에 같이 설치되어 로컬 환경에서 동작할 수 있다.



4. 타입 단언Type Assertion

개발자가 해당 타입에 대해 확신이 있을 때 사용하는 타입 지정 방식. 타 프로그래밍 언어의 타입 캐스팅과 비슷한 개념이며 타입스크립트가 컴파일 할 때, 타입 단언이 선언된 부분에 대해서는 타입스크립트는 특별히 타입을 체크하지 않고, 데이터의 구조도 신경쓰지 않습니다.

// 일반적인 타입 선언 방식
const na: string = 'Capt';

// 타입 단언을 사용한 선언 방식
const na = 'Capt' as string;
  • 타입 단언은 타입스크립트 컴파일러보다 개발자가 더 해당 타입을 잘 알고 있을 때 사용해야 한다! 타입 단언이 선언된 부분은 타입스크립트에서 타입도 체크하지 않고 구조도 검사하지 않기 때문.

타입 단언과 타입 가드

유니온 타입의 문제를 해결하는 방법 중 하나는 타입 단언을 사용하는 것이다. 타입 단언을 사용해주면 공통되지 않는 변수도 접근이 가능해지기 때문. 그런데 코드가 길어지면 길어질 수록 코드 가독성에 문제가 발생한다.

if ((kim as Developer).skill) {
    console.log((kim as Developer).skill);
}
else if ((kim as Person).age) {
    const age = (kim as Person).age;
    console.log(age);
}
(...)

이 때 사용이 가능한 것이 바로 타입 가드. 타입 가드 개념이 도입된 함수를 하나 만들고, 이를 사용할 경우 타입 단언을 사용했을 때보다 코드를 줄일 수 있다.

function isDeveloper(target: Developer | Person): target is Developer {
    return (target as Developer).skill !== undefined;
}

Developer 형식의 target의 skill이 값이 존재할 때 값을 반환하고, 함수의 인자로 사용된 target의 skill이 존재하느냐 여부를 가지고 targer의 타입이 Developer인지를 구분한다. 그리고 이 함수를 이용하면..

if (isDeveloper(kim)) {
    console.log(kim.skill);
}
else {
    console.log(kim.age);
}

타입 단언을 사용하지 않고 유니언 타입의 단점을 해결하면서 코드의 갯수도 확연히 줄어들게 된다.



5. 타입 호환Type Compatibility

타입스크립트 코드에서 특정 타입이 다른 타입과 호환이 되는지를 뜻한다.

interface Ironman {
    name: string;
}

class Avengers {
    name: string;
}

let i: Ironman;
i = new Avengers();

변수 i의 타입은 Ironman으로 정의되었는데, Avengers 생성자 함수로 인스턴스를 선언했음에도 에러가 발생되지 않는다. 인터페이스와 클래스가 보유하고 있는 요소의 이름과 타입이 모두 일치하기 때문이다.

구조적 타이핑structural typing

코드 구조 관점에서 타입이 서로 호환되는지의 여부를 판단하는 것.

interface Avengers {
    name: string;
}

let hero: Avengers;

let capt = { name: "Captain", location: "Pangyo" };
hero = capt;

capt의 속성 중에 name이 있기 때문에 capt은 hero 타입에 호환이 가능하다.

내부 구조가 완전 동일하면 100% 호환이 되지만, 일부만 동일한 경우에는? 구조에 따라 호환여부가 달라진다.

interface Developer {
    name: string;
    skill: string;
}

interface Person {
    name: string;
}

let developer: Developer = {name: 'kim', skill: 'cook'};
let person: Person = {name: 'lee'};

이 경우에는 에러가 발생된다. person의 구조가 developer보다 작아서 호환이 되지 않기 때문. 반대의 경우에는 호환이 가능하다. developer쪽의 구조가 더 크기 때문.

구조적 타이핑structural typing - 클래스

클래스의 경우도 동일하다. 다만 생성자 키워드를 사용하는 경우에는 각각의 내부 구조를 기준으로 호환 여부를 판단한다.

구조적 타이핑structural typing - 함수

함수도 마찬가지.

let add1 = function (a: number) {

}
let add2 = function (a: number, b: number) {

}

add1 = add2; // 에러!
add2 = add1; // 문제없음

add1의 구조가 더 크기때문에 호환이 되지 않는다, 반대의 경우에는 호환이 가능하다.

구조적 타이핑structural typing - 제네릭

interface Empty<Type> {
    // ...
}
let x: Empty<number>;
let y: Empty<string>;

x = y;
y = x;

속성(member 변수)이 없기 때문에 x와 y는 같은 타입으로 간주되지만, 인터페이스에 속성이 있어서 제네릭의 타입 인자가 속성에 할당된다면 서로 호환이 되지 않는다.

interface NotEmpty<Type> {
    data: Type;
}

let x2: NotEmpty<number>;
let y2: NotEmpty<string>;

x2 = y2;
y2 = x2;



6. 타입 모듈화

프로젝트 규모가 거대해질 수록 같은 파일에 interface나 type을 선언하는 것은 코드 가독성에 악영향을 준다. 이럴 때 모듈화를 사용하여 interface나 type를 다른 파일로 독립시켜주면 좋다.

interface Todo {
    title: string;
    checked: boolean;
}

export { Todo };

(...)

import { Todo } from "./types";

const item: Todo = {
    title: 'todo1',
    checked: false,
};



7. 유틸리티 타입

이미 정의해 놓은 타입을 변환할 때 사용하기 좋은 타입 문법. 기존의 인터페이스, 제네릭 등의 기본 문법으로 충분히 타입을 변환할 수 있지만, 유틸리티 타입을 쓰면 훨씬 더 간결한 문법으로 타입을 정의할 수 있다.

픽Pick 타입

인터페이스에서 여러 타입을 정의해두었을 때, 일부 타입만 사용한다.

interface Hero {
  name: string;
  skill: string;
}

type human = Pick<Hero, 'name'>;

const human: Pick<Hero, 'name'> = {
  name: '스킬이 없는 사람',
};

Pick 타입으로 type 자체를 선언할 수도 있고, 변수나 함수에서 사용할 수도 있다.

오밋Omit 타입

특정 타입에서 지정된 속성만 제거한 타입을 정의해준다. Pick 타입과는 반대 역할을 하는 것.

interface AddressBook {
  name: string;
  phone: number;
  address: string;
  company: string;
}
const phoneBook: Omit<AddressBook, 'address'> = {
  name: '재택근무',
  phone: 12342223333,
  company: '내 방',

  // 'Omit<AddressBook, "address">' 형식에 'address'이(가) 없습니다.
  // address: 'dd', // 에러!
}

omit 타입을 사용하여 AddressBook 인터페이스에서 "address"만 사용하지 않는 새로운 타입을 정의하였다. 따라서 phoneBook에서는 address를 사용할 수가 없다.

파셜Partial 타입

특정 타입의 부분 집합을 만족하는 타입을 정의할 수 있다.

interface Address {
  email: string;
  address: string;
}

type MayHaveEmail = Partial<Address>;
const me: MayHaveEmail = {}; // 가능
const you: MayHaveEmail = { email: 'test@abc.com' }; // 가능
const all: MayHaveEmail = { email: 'capt@hero.com', address: 'Pangyo' }; // 가능

유틸리티 타입은 어떻게 동작하는 것인가?

옵셔널, 유니온 타입 등 이전 시간에 다루었던 개념들을 타입스크립트에서 내부적으로 활용하여 유틸리티 타입의 기능으로 조합하였다.

파셜Partial 타입을 예로 들자면 타입스크립트는 내부적으로 옵셔널 방식을 사용해서 파셜 타입의 동작 방식을 구현해주고 있다. 여기에 제네릭, 맵드 타입 등의 개념이 섞여 사용되어 최종적으로 파셜 타입이 만들어지는 것.



8. 맵드 타입

기존에 정의되어 있는 타입을 새로운 타입으로 변환해 주는 문법. 자바스크립트 map() 함수를 타입에 적용한 것이라고 보면 이해하기 쉽다.

map 함수

배열을 다룰 때 유용한 자바스크립트 내장 API.

var arr = [{ id: 1, title: '함수'}, { id: 2, title: '변수'}, { id: 3, title: '인자'}];

var result = arr.map( function (item) {
  return item.title;
});

console.log(result); // ['함수', '변수', '인자'];

변수 result에는 배열 arr을 map 함수로 순회하여 내부 요소 중 title의 값만을 반환받아 새로운 배열이 선언된다.

맵드 타입의 기본 문법

맵드 타입은 위에서 살펴본 자바스크립트의 map 함수를 타입에 적용했다고 보면 이해가 쉽다.

type Heroes = 'Hulk' | 'Thor' | 'Capt';

type HeroAges = { [K in Heroes]: number };

HeroAges 타입에는 Heroes의 각 요소들을 key 값으로 가지고, number 형식의 value를 갖는 요소들이 들어가게 된다.

const ages: HeroAges = {
  Hulk: 100,
  Thor: 100,
  Capt: 100,
};

따라서 HeroAges 타입을 적용한 ages 객체는 위와 같은 형태를 가지게 되는데, 당연하게도 각 key의 value는 number 이외의 데이터 타입을 사용할 수가 없게 된다.

profile
프론트엔드 개발자를 준비하고 있습니다.

0개의 댓글