📢 어렵고 정석적인 개념 설명보다는 저같은 초보자도 이해하기 쉽게 정리하는 것을 원칙으로 포스팅하고 있습니다. 😄

[TypeScript] 타입스크립트 기초 : 변수 뒤에 타입 달기

원시 타입

원시 타입이란 숫자, 문자열, boolean, null, undefined 등 동시에 한 개의 값만 저장할 수 있는 타입들을 말한다.

배열과 객체를 제외한 거의 대다수의 타입들을 원시 타입이라고 할 수 있다.

 

Number(숫자) 타입

let num1: number = 1;
let num2: number = Infinity;
let num3: number = NaN;

num1 = 'str'; // Error!
num1.toUpperCase(); // Error!

숫자 타입에는 말 그대로 숫자 그리고 무한대(Infinity), NaN까지 값으로 들어간다.

처음에 변수를 선언할 때 타입까지 지정하기 때문에 해당하는 타입에 맞는 값으로만 재할당이 가능하다.

위의 예시를 보면, 숫자 타입인 num1 변수에는 숫자 타입을 제외한 다른 타입이 들어올 수가 없다. 또한, 문자열 메서드인 toUpperCase() 역시 오류가 나는 것을 확인해 볼 수 있다.

 

String(문자열) 타입

let str1: string = 'hello';

str1 = 123; // Error!
str1.toFixed(); // Error!

문자열 타입 역시 숫자 타입과 똑같다. 문자열로 타입을 지정한 변수에는 문자열만 들어갈 수 있으며, 메서드 역시 문자열 메서드만 가능하다.

 

Boolean, Null, undefined 타입

let bool1: boolean = true;
let null1: null = null;
let unde1: undefined = undefined;

let num: number = null; // Error?

이 외에도 boolean, null, undefined도 변수의 타입으로 지정할 수 있다.

 

null에 대해서는 한 가지 주의할 게 있다. null은 자바스크립트에서 값이 아직 들어오지 않은 상태라는 의미로 자주 쓰인다. 따라서, 변수를 지정하고 초깃값으로 null을 주는 경우가 종종 있다. 하지만, 타입스크립트에서는 null을 타입으로 쓰기 때문에 null을 값으로 쓰려면 변수의 타입이 null이어야만 한다. 그런데 이러면 애초에 null을 쓰는 이유가 없어진다.

(변수의 초깃값으로 null을 씀 ☞ null은 null 타입 변수에서만 쓸 수 있음 ☞ 초깃값의 의미가 없어짐 ☞ null을 왜 써..?)

 

null을 다른 타입의 변수에서도 쓰기 위해서는 컴파일러 옵션에 strictNullChecks를 "false"로 설정해 주면 된다.

이 옵션은 strict 옵션의 하위 옵션으로 strict가 true일 경우 자동으로 이 옵션도 true가 되기 때문에, 만약 strict 옵션을 true로 설정해 놓았다면 따로 strictNullChecks 옵션의 값을 false로 설정해주어야 한다. (만약 strict 옵션을 설정하지 않았으면 굳이 줄 필요 없음)

 

리터럴 타입

let num: 10 = 10;
let str: 'hello' = 'hello';
let num2: 1 | 2 | 3 = 1;

num = 20; // Error!
str = 'hi'; // Error!
num2 = 2;
num2 = 4; // Error!

변수에 타입만 정하는 게 아니라 까지 정할 수도 있다. 위의 코드를 보면 num: 10은 숫자형 타입이면서 값이 10인 값만 변수에 들어올 수 있다.

 

🤔 리터럴 타입과 const의 차이점은?
리터럴 타입은 답이 정해져 있어서 새로운 값을 못 넣고, const는 상수이기 때문에 재할당을 하지 못한다는 점에서 뭔가 비슷해 보일 수도 있다. 이 둘은 사실 전혀 다른 범주의 개념으로 초점을 어디에 두냐에 따라 차이가 있다.
리터럴 타입은 '값'에 초점을 두고 있고, const는 변수의 '할당'에 초점을 두고 있다.

쉽게 비유하자면, 리터럴 타입은 우리가 시험을 볼 때 '객관식의 보기'와 같은 개념이다. 만약 num: 10인 경우 객관식의 보기가 1개밖에 없는 거고 num: 1 | 2 | 3일 경우 객관식의 보기가 3개라서 이 셋 중 하나를 고르는 개념이다.
(const는 변수 자체를 상수로 만드는 역할로 타입과는 전~혀 상관없다. 단지 뭔가 정해져 있다는 점이 비슷...)

 

배열과 튜플

배열

let arr0: Array<number> = [1, 2, 3]; // 제네릭
let arr1: number[] = [1, 2, 3]; // 배열 타입
let arr2: number[][] = [[1, 2], [3, 4]]; // 2차원 배열
let arr3: (number | string)[] = [1, 'a']; // 섞어서도 가능

배열의 타입을 지정하는 방법에는 제네릭 방식배열 타입 방식이 있다. 주로 제네릭보다는 간단한 배열 타입을 사용하며, 원시 타입과 비슷하게 타입을 써주고 끝에 [] 대괄호만 써주면 끝이다.

n차원 배열의 경우 n의 개수에 맞춰 []의 개수를 더 늘려주면 되고, 바(|)를 이용해서 배열 안에 여러 타입을 섞어 쓸 수도 있다.

 

튜플

let tup1: [number, string] = [1, 'a'];

tup1 = ['a', 1]; // Error! 인덱스 위치에 따른 타입 불일치
tup1 = [1, 'a', 2]; // Error! 지정된 길이 초과

tup1.push(1); // Not Error

튜플은 배열은 배열인데, 인덱스 위치에 따른 타입과 배열의 길이가 정해진 좀 더 엄격한 배열이라고 보면 된다.

주로 배열의 길이가 정해져 있고, 그 배열의 인덱스 위치에 따라 타입이 정해져 있을 때 주로 사용된다.

 

객체

let obj: {
    id?: number; // id?의 '?'는 없어도 되는 것
    name: string;
    readonly domain: string; // readonly로 설정한 프로퍼티는 재할당 불가능
} = {
    name: 'shawn',
    domain: 'shawnkim.tistory.com'
}

obj.domain = 'add'; // Error!

객체는 타입을 정할 때 프로퍼티마다 하나하나 타입을 지정해줘야 한다. 타입을 지정해 줄 때는 몇 가지 특수한 프로퍼티를 설정해 줄 수도 있다. 프로퍼티 이름 뒤에 '?' 물음표를 달아주면 이 프로퍼티는 선택적 프로퍼티가 된다.

선택적 프로퍼티란, 상황에 따라 생략이 가능한 프로퍼티로 위의 예시 코드에서 obj 객체에 id 프로퍼티가 없는데도 오류가 나지 않는 이유가 바로 id 프로퍼티가 선택적 프로퍼티이기 때문이다.

또한, 예시 코드의 domain 프로퍼티 앞에는 readonly라고 되어 있는데 이것은 읽기 전용 프로퍼티로 프로퍼티의 값을 수정할 수 없게 만드는 장치이다.

 

타입 별칭(Type Alias)

let user: {
    id: number;
    name: string;
    favorite: string;
    color: string;
    location: string;
    country: string;
} = {
    id: 1,
    name: 'kim',
    favorite: 'dog',
    color: 'green',
    location: 'seoul',
    country: 'korea'
}

let user2: {
    id: number;
    name: string;
    ... // 이걸 일일이 다 쳐줘야 해?
} = {
    id: 2,
    name: 'hone',
    ...
}

객체의 경우에는 프로퍼티마다 모두 타입을 정해줘야 하기 때문에 만약 프로퍼티의 수가 많아질 경우엔 하나하나 타입을 쓰는 것도 꽤 번거로운 일이 될 수 있다. 특히, 위의 예시 코드처럼 같은 형식의 객체가 여러 개일 경우에는 이미 객체만으로도 코드량이 많은데 타입까지 써주면 코드량이 2배로 많아져서 가독성이 많이 떨어질 수가 있다.

 

type User = {
    id: number;
    name: string;
    favorite: string;
    color: string;
    location: string;
    country: string;
}

let user: User = {
    id: 1,
    name: 'shawn',
    ...
}

이를 방지하기 위해 타입들을 변수로 선언하듯 하나에 담아서 선언할 수도 있다. 타입 별칭은 꼭 객체 형태가 아니어도 되고, 객체에서만 사용하는 것도 아니다. 그냥 변수처럼 타입을 저장하는 개념이라고 생각하면 된다. (타입 자리에 들어간다.)

 

인덱스 시그니처(Index Signature)

type AllString = {
    [key: string]: string;
    name: string; // 필수로 들어가줘야 하는 건 이렇게 별도로 써준다.
    city: number; // Error! 시그니처가 string이면 모두 string이여야 함
}

let user: Allstring = {
    name: 'shawn',
    color: 'green',
    country: 'korea',
    city: 'seoul'
}

// 프로퍼티의 타입이 모두 동일할 경우 밑에처럼 굳이 하나하나 다 쓸 필요가 없다.
let user: {
    name: string;
    color: string;
    country: string;
    city: string;
}

만약, 프로퍼티의 타입들이 모두 한 가지로 통일되었다면 굳이 하나하나 쓸 필요 없이 인덱스 시그니처를 통해 한 줄로 처리해 줄 수도 있다. 위의 코드처럼 key의 타입과 value의 타입을 적어주면 된다. 만약, 꼭 포함시켜 주고 싶은 프로퍼티가 있다면 name: string처럼 명시해 줄 수도 있다. 대신, 인덱스 시그니처로 설정된 타입만 가능하다.

 

열거형(Enum) 타입

enum Role {
    ADMIN, // 0
    USER, // 1
    GUEST // 2
}

enum Language {
    korean = 'ko',
    english = 'en'
}

const user = {
    name: 'shawn',
    role: Role.ADMIN,
    language: Language.korean
}

enum 타입은 오직 타입스크립트에서만 존재하는 타입으로 여러 개의 값을 나열하는 용도로 사용된다. 타입 객체 안에 값이 없으면 첫 번째 인덱스부터 자동으로 0부터 값이 할당되며, 순서대로 +1씩 증가한다.

값으로 문자를 넣을 수도 있다. enum 타입 역시 리터럴 타입처럼 객관식 문제의 보기와 비슷하다. (답은 이렇게 있으니까 이 중에서 하나 골라라는 식)

 

값을 알 수 없을 때 : any, unknown

let any: any = 1;
any = 'hello';
any = true;
any.toUpperCase();
any.toFixed();

any는 어떤 타입이든 들어올 수 있는 타입으로, 변수의 타입을 아직 정하지 못했을 때 사용한다.

하지만, any 타입의 경우에는 타입 검사를 받지 않기 때문에 더욱 신중하게 사용해야 한다. 사실 타입스크립트를 사용하는 목적이 타입을 지켜서 오류를 미연에 방지하기 위해 사용하는 건데 타입 검사를 받지 않는 any를 사용하면 솔직히 타입스크립트를 사용하는 이유가 없어진다.

따라서, 가급적이면 any 타입은 사용하지 않는 게 좋다. 그렇다면 값이 아직 정해지지 않는 변수는 어떤 타입으로 정해야 하는 걸까?

 

let unk: unknown = 1;
unk = 'hello';
unk = true;

let unm: number;
num = unk; // Error! (값으로는 사용할 수 없음)
unk * 8; // Error! (숫자라도 어떠한 연산에 사용 불가)
unk.toFixed(); // Error! (어떠한 메서드도 사용 불가)

if (typeof unk === 'number') {
    num = unk; // typeof 조건문이 있으면 값으로 사용 가능 (타입이 변하기 때문)
}

any 타입의 대안으로 사용할 수 있는 타입은 바로 unknown 타입이다. unknown 타입은 any 타입과 마찬가지로 어떠한 타입이든 값으로 쓸 수 있다. 대신, any와 다른 점이 있다면 unknown 타입은 다른 변수에 값으로는 사용할 수 없다.

값을 할당받는 입장이지 값으로서 할당되는 입장은 아닌 것이다. 또한, 값이 숫자 타입이라 할지라도 어떠한 연산이든 참가할 수 없고 메서드 또한 사용할 수 없다.

즉, unknown 타입은 값을 받기만 할 뿐 자기 자신을 희생할 줄 모르는 아이인 것이다. (베풀 줄 모르고 받기만 하는 아이...)

 

값이 없을 때 : void, never

function func(): void {
    console.log('hello');
}

let a: void; // a에는 아무것도 담을 수 없다.
a = undefined; // undefined은 예외

공허를 의미하는 void 타입은 아무것도 없음을 의미한다. 즉, 아무런 값이 없을 때 사용한다.

위 예시 코드를 보면 func() 함수의 경우 return 하는 값이 아무것도 없다. 이런 경우 void 타입을 사용한다.

변수에 사용할 경우 undefined를 제외한 어떠한 타입도 할당되지 않는다.

 

function func1(): never {
    while (true) {...} // 무한 루프 => 무한 루프에 갇혔는데 답이 나오는 것 자체가 모순
}

function func2(): never {
    throw new Error(); // 에러 메시지 출력 => 에러인데 계속 동작하는 것 자체가 모순
}

never 타입은 답이 나오는 게 불가능할 경우를 의미한다. 위의 예시 코드처럼 무한 루프가 걸려 있거나 오류 메시지를 출력하는 경우처럼 이런 상황에 답이 나오는 것 자체가 모순인 경우 never 타입을 사용한다.

 

1. 원시형 타입 : let num: number = 1
2. 배열 : let arr: string[] = ['a', 'b']
3. 튜플 : let arr: [string, number] = ['a', 1]
4. 객체: let obj: { id: number;  name: string; } = { id: 1, name: 'shawn' }
5. 타입 별칭: type Example = { id: number; name: string; ... } // 타입 모음을 변수처럼 저장한 것
6. 인덱스 시그니처: let obj: { [key: string]: number } = { id: 1, pass: 2343 } // 객체의 value 타입이 모두 같을 경우
7. enum : enum Role { A, B, C } // A-B-C 순서대로 0-1-2 할당
8. any : 타입 검사 패스, 어떠한 타입이든 다 받음
9. unknown : 어떠한 타입이든 다 받지만, 이 타입을 사용하는 건 불가능. 그저 저장만 할 뿐.
10. void : 반환되는 값이 없을 때 사용
11. never : 이 상황에서 값이 나오는 게 모순적일 때 (말도 안 될 때) 사용