타입스크립트를 프로젝트에서는 많이 사용해봤지만 개념을 제대로 공부해본 적은 없기 때문에 핸드북 내용을 정리해보려 한다. 자료는 공식 타입스크립트 핸드북과 캡틴판교님의 타입스크립트 핸드북을 참고할 예정.
타입스크립트란?
타입스크립트는 자바스크립트에 타입을 부여한 언어이다. 타입스크립트는 자바스크립트와 달리 브라우저에서 실행하려면 파일을 변환해주는 컴파일 과정을 거쳐야 한다.
타입스크립트를 써야 하는 이유
에러의 사전 방지
타입스크립트를 사용하면 에러를 사전에 방지할 수 있다.
// math.js
function sum(a, b) {
return a + b;
}
위의 코드는 두 숫자의 합을 구하는 함수 코드이다.
sum(10, 20); // 30
sum('10', '20'); //1020
숫자 10과 20을 더하게 되면 원하는 결과값인 30을 얻겠지만, 문자열을 더하게 되면 문자열이 연결되기 때문에 '1020'이라는 결과가 나타난다.
아래와 같이 타입을 지정하면 의도하지 않은 코드의 동작을 예방할 수 있다.
// math.ts
function sum(a: number, b: number) {
return a + b;
}
sum('10', '20'); // Error: '10'은 number에 할당될 수 없습니다.
코드 자동 완성과 가이드
타입스크립트의 또 다른 장점은 코드를 작성할 때 개발 툴의 기능을 최대로 활용할 수 있다는 것이다.
// math.js
function sum(a, b) {
return a + b;
}
var total = sum(10, 20);
total.toLocaleString();
위 코드는 두 숫자의 합을 구한 다음 toLocaleString()을 적용한 코드이다. 자바스크립트는 코드를 작성하는 시점에는 total이라는 변수의 타입이 number라는 것을 인지하지 못한다. (개발자가 스스로 sum 함수의 결과를 예상하고 타입이 number라고 가정한 상태에서 number의 API인 toLocaleString을 코딩하게 되는 것이다.)
total이라는 값이 정해져있지 않기 때문에 toLocaleString()을 일일이 작성했다. 만약 오탈자가 났다면 이 파일을 실행했을 때만 오류를 확인할 수 있었을 것이다.
function sum(a: number, b: number): number {
return a + b;
}
var total = sum(10, 20);
total.toLocaleString();
반면 타입스크립트로 작성할 경우에는 변수 total에 대한 타입이 지정되어 있기 때문에 VSCode에서 해당 타입에 대한 API를 미리보기로 띄워줄 수 있고, 따라서 API를 다 일일이 치지 않아도 된다.
원시 타입
string, number, boolean
자바스크립트에서 아주 흔하게 사용되는 세 가지의 원시 타입이다.
- string: 문자열
- number: 숫자(정수를 위한 값을 별도로 가지지 않으므로 int, float와 같은 것은 존재하지 않음)
- boolean: true/false
배열
[1, 2, 3]과 같은 배열의 타입을 지정할 때 number[] 구문을 사용할 수 있다. Array<number>와 같은 형태로도 적을 수 있다. ([number]는 전혀 다른 의미를 가짐)
any
타입스크립트는 any라는 특별한 타입을 가지고 있다. 이는 특정 값으로 인해 타입 검사 오류가 발생하는 것을 원하지 않을 때 사용할 수 있다.
어떤 값의 타입이 any이면 구문적으로 유효한 것이라면 무엇이든 할 수 있다. any 타입은 타입스크립트를 안심시킨다는 목적 단지 하나 때문에 긴 타입을 새로 정의하고 싶지 않을 때 유용하게 사용할 수 있다.
let obj: any = { x: 0 };
// 아래 이어지는 코드들은 모두 오류 없이 정상적으로 실행됩니다.
// `any`를 사용하면 추가적인 타입 검사가 비활성화되며,
// 당신이 TypeScript보다 상황을 더 잘 이해하고 있다고 가정합니다.
obj.foo();
obj();
obj.bar = 100;
obj = "hello";
const n: number = obj;
noImplicitAny
타입이 지정되지 않은 값에 대해 타입스크립트가 타입을 추론할 수 없다면 컴파일러는 기본적으로 any 타입을 부여한다.
하지만 any는 타입 검사가 이루어지지 않기 때문에 이런 상황은 보통 선호되지 않는다. noImplicitAny를 사용하면 암묵적으로 any로 간주하는 모든 경우에 오류를 발생시킨다.
변수에 대한 타입 표기
변수를 선언할 때 변수의 타입을 명시적으로 지정하기 위해 타입 표기를 추가할 수 있으며, 이는 선택사항이다.
let myName: string = "Alice";
// ^^^^^^^ 타입 표기
타입스크립트는 가능하다면 자동으로 코드 내에 있는 타입들을 추론하고자 한다. 변수의 타입은 해당 변수의 초기값의 타입을 바탕으로 추론된다.
// 타입 표기가 필요하지 않습니다. 'myName'은 'string' 타입으로 추론됩니다.
let myName = "Alice";
함수
타입스크립트에서는 함수의 입력 및 출력 타입을 지정할 수 있다.
매개변수 타입 표기
함수가 허용할 매개변수의 타입을 선언하기 위하여 각 매개변수 뒤에 타입을 표기할 수있다. 매개변수의 타입은 매개변수 이름 뒤에 표기한다.
매개변수에 타입이 표기되었다면, 해당 함수에 대한 인자는 검사가 이루어진다.
function greet(name: string) {
// ^^^^^^^^
console.log("Hello, " + name.toUpperCase() + "!!");
}
반환 타입 표기
반환 타입은 매개변수 목록 뒤에 표기한다.
function getFavoriteNumber(): number {
// ^^^^^^^^
return 26;
}
타입스크립트가 해당 함수에 들어있는 return 문을 바탕으로 반환 타입을 추론할 것이기 때문에 반환 타입은 표기하지 않아도 되는 것이 일반적이다.
때에 따라 문서화를 목적으로, 또는 코드의 잘못된 수정을 방지하고자, 혹은 지극히 개인적인 선호에 의해 명시적인 타입 표기를 수행하는 코드도 존재한다.
익명 함수
함수가 코드상에서 위치한 곳을 보고 해당 함수가 어떻게 호출될 지 알아낼 수 있다면 타입스크립트는 해당 함수의 매개변수에 자동으로 타입을 부여한다.
// 아래 코드에는 타입 표기가 전혀 없지만, TypeScript는 버그를 감지할 수 있습니다.
const names = ["Alice", "Bob", "Eve"];
// 함수에 대한 문맥적 타입 부여
names.forEach(function (s) {
console.log(s.toUppercase());
// -> Property 'toUppercase' does not exist on type 'string'. Did you mean 'toUpperCase'?
});
// 화살표 함수에도 문맥적 타입 부여는 적용됩니다
names.forEach((s) => {
console.log(s.toUppercase());
// -> Property 'toUppercase' does not exist on type 'string'. Did you mean 'toUpperCase'?
});
매개변수 s에는 타입이 표기되지 않았음에도 불구하고 타입스크립트는 s의 타입을 알아내기 위해 배열의 추론된 타입과 forEach 함수의 타입을 활용했다.
이를 문맥적 타입 부여라고 하는데, 함수가 실행되는 문맥을 통해 해당 함수가 가져야 하는 타입을 알 수 있다.
객체 타입
원시 타입을 제외하고 가장 많이 마주치는 타입은 객체 타입이다.
객체 타입을 정의하려면, 해당 객체의 프로퍼티들과 각 프로퍼티의 타입들을 나열하기만 하면 된다.
// 매개 변수의 타입은 객체로 표기되고 있습니다.
function printCoord(pt: { x: number; y: number }) {
// ^^^^^^^^^^^^^^^^^^^^^^^^
console.log("The coordinate's x value is " + pt.x);
console.log("The coordinate's y value is " + pt.y);
}
printCoord({ x: 3, y: 7 });
각 프로퍼티를 구분할 때 구분자로 ,(comma) 또는 ;(semicolon) 를 사용할 수 있고, 가장 마지막에 위치한 구분자의 표기는 선택 사항이다.
각 프로퍼티의 타입 표기 또한 선택 사항이다. 타입을 지정하지 않는다면 any 타입으로 간주한다.
옵셔널 프로퍼티
객체 타입은 일부 또는 모든 프로퍼티의 타입을 선택적인 타입, 즉 optional로 지정할 수 있다. 프로퍼티의 이름 뒤에 ?를 붙이면 된다.
function printName(obj: { first: string; last?: string }) {
// ...
}
// 둘 다 OK
printName({ first: "Bob" });
printName({ first: "Alice", last: "Alisson" });
자바스크립트에서는 존재하지 않는 프로퍼티에 접근했을 때 런타임 오류가 발생하지 않고 undefined 값을 얻는다. 때문에 옵셔널 프로퍼티를 읽을 경우 해당 값을 사용하기에 앞서 undefined인지 확인해야 한다.
function printName(obj: { first: string; last?: string }) {
console.log(obj.last.toUpperCase());
// 오류! -> Object is possibly 'undefined'
// 최신 JavaScript 문법을 사용하였을 때 안전한 코드
console.log(obj.last?.toUpperCase());
}
유니언 타입(Union Types)
유니언 타입 정의하기
유니언 타입은 서로 다른 두 개 이상의 타입들을 사용해 만드는 것으로, 유니언 타입의 값은 타입 조합에 사용된 타입 중 무엇이든 하나를 타입으로 가질 수 있다. 조합에 사용된 각 타입을 유니언 타입의 멤버라고 부른다.
function printId(id: number | string) {
console.log("Your ID is: " + id);
}
// OK
printId(101);
// OK
printId("202");
유니언 타입 사용하기
유니언을 다룰 때는 해당 유니언 타입의 모든 멤버에 대하여 유효한 작업일 때에만 허용된다. 예를 들어 string | number 이라는 유니언 타입의 경우, string 타입에만 유효한 메서드는 사용할 수 없다.
function printId(id: number | string) {
console.log(id.toUpperCase());
// 에러! -> Property 'toUpperCase' does not exist on type 'string | number'.
// Property 'toUpperCase' does not exist on type 'number'.
}
이를 해결하려면 유니언을 좁혀 타입스크립트가 코드 구조를 바탕으로 어떤 값을 구체적인 타입으로 추론할 수 있도록 해야 한다.
function printId(id: number | string) {
if (typeof id === "string") {
// 이 분기에서 id는 'string' 타입을 가집니다
console.log(id.toUpperCase());
} else {
// 여기에서 id는 'number' 타입을 가집니다
console.log(id);
}
}
function welcomePeople(x: string[] | string) {
if (Array.isArray(x)) {
// 여기에서 'x'는 'string[]' 타입입니다
console.log("Hello, " + x.join(" and "));
} else {
// 여기에서 'x'는 'string' 타입입니다
console.log("Welcome lone traveler " + x);
}
}
때로는 유니언의 모든 멤버가 무언가 공통점을 가질 수도 있다.
예를 들어 배열과 문자열은 둘 다 slice 메서드를 내장한다. 유니언의 모든 멤버가 어떤 프로퍼티를 공통으로 가진다면 좁히기 없이도 해당 프로퍼티를 사용할 수 있게 된다.
타입 별칭(Type Aliases)
똑같은 타입을 한 번 이상 재사용하거나 또 다른 이름으로 부르고 싶은 경우도 존재한다. 타입 별칭은 이런 경우를 위해 존재하며, 타입을 위한 이름을 제공한다.
type Point = {
x: number;
y: number;
};
// 앞서 사용한 예제와 동일한 코드
function printCoord(pt: Point) {
console.log("The coordinate's x value is " + pt.x);
console.log("The coordinate's y value is " + pt.y);
}
printCoord({ x: 100, y: 100 });
타입 별칭을 사용하면 객체 타입 뿐만 아니라 모든 타입에 대해 새로운 이름을 부여할 수 있다.
type ID = number | string;
타입 별칭은 단지 별칭에 지나지 않는다는 점에 유의하자. 즉, 타입 별칭을 사용해도 동일 타입에 대해 각기 구별되는 '여러 버전'을 만드는 것은 아니다.
아래 코드는 틀린 것처럼 보일 수도 있지만 정상적으로 동작한다.
type UserInputSanitizedString = string;
function sanitizeInput(str: string): UserInputSanitizedString {
return sanitize(str);
}
// 보안 처리를 마친 입력을 생성
let userInput = sanitizeInput(getInput());
// 물론 새로운 문자열을 다시 대입할 수도 있습니다
userInput = "new input";
인터페이스(Interface)
인터페이스 선언은 객체 타입을 만드는 또 다른 방법이다.
interface Point {
x: number;
y: number;
}
function printCoord(pt: Point) {
console.log("The coordinate's x value is " + pt.x);
console.log("The coordinate's y value is " + pt.y);
}
printCoord({ x: 100, y: 100 });
타입 별칭을 사용한 경우와 마찬가지로 위 예시 코드는 마치 타입이 없는 임의의 익명 객체를 사용하는 것처럼 동작한다. 타입스크립트는 오직 printCoord에 전달된 값의 구조에만 관심을 가진다. 즉, 예측된 프로퍼티를 가졌는지 여부만을 따진다. 이처럼 타입이 가지는 구조와 능력에만 관심을 가진다는 점은 타입스크립트가 구조적 타입 시스템이라고 불리는 이유이다.
타입 별칭과 인터페이스의 차이점
타입 별칭과 인터페이스는 매우 유사하며 대부분의 경우 둘 중 하나를 자유롭게 선택해 사용할 수 있다. interface가 가지는 대부분의 기능은 type에서도 동일하게 사용 가능하다.
둘의 가장 핵심적인 차이는 type은 새 프로퍼티를 추가하도록 개방될 수 없는 반면, interface의 경우 항상 확장될 수 있다는 점이다.
타입 단언(Type Assertions)
때로는 타입스크립트보다 당신이 어떤 값의 타입에 대한 정보를 더 잘 아는 경우도 존재한다.
예를 들어 코드 상에서 document.getElementById가 사용되는 경우, 타입스크립트는 이때 HTMLElement중 무언가가 반환된다는 것만을 알 수 있지만, 당신은 페이지 상에서 사용되는 ID로는 언제나 HTMLCanvasElement가 반환된다는 사실을 알고 있을 수 있다.
이런 경우 타입 단언을 사용하면 타입을 좀 더 구체적으로 명시할 수 있다.
const myCanvas = document.getElementById("main_canvas") as HTMLCanvasElement;
꺾쇠괄호를 사용하는 것 또한 가능하며(코드가 .tsx 파일이 아닌 경우), 이는 동일한 의미를 가진다.
const myCanvas = <HTMLCanvasElement>document.getElementById("main_canvas");
보다 구체적이거나 덜 구체적인 버전의 타입으로 변환하는 타입 단언만이 허용된다. 아래와 같은 "불가능한" 강제 변환을 방지한다.
const x = "hello" as number;
/* 에러! -> Conversion of type 'string' to type 'number' may be a mistake
because neither type sufficiently overlaps with the other.
If this was intentional, convert the expression to 'unknown' first. */
복잡하긴 하지만 유효할 수 있는 강제 변환을 할 경우에는 두 번의 단언을 사용할 수 있다. any로 우선 변환한 뒤, 그 다음 원하는 타입으로 변환하면 된다.
const a = (expr as any) as T;
리터럴 타입
string과 number와 같은 일반적인 타입 외에도, 구체적인 문자열과 숫자 값을 타입 위치에서 지정할 수 있다.
리터럴을 유니언과 함께 사용하면, 보다 유용한 개념들을 표현할 수 있게 된다. 예를 들어, 특정 종류의 값들만을 인자로 받을 수 있는 함수를 정의하는 경우가 있다.
function printText(s: string, alignment: "left" | "right" | "center") {
// ...
}
printText("Hello, world", "left");
printText("G'day, mate", "centre"); // ERROR
물론 리터럴이 아닌 타입과도 사용 가능하다.
interface Options {
width: number;
}
function configure(x: Options | "auto") {
// ...
}
configure({ width: 100 });
configure("auto");
configure("automatic"); // ERROR
리터럴 추론
객체를 사용해 변수를 초기화하면 타입스크립트는 해당 객체의 프로퍼티는 이후에 그 값이 변화될 수 있다고 가정한다.
function handleRequest(url: string, method: "GET" | "POST") {
// ...
}
const req = { url: "https://example.com", method: "GET" };
handleRequest(req.url, req.method);
// ^^^^^^^^^^^
// Argument of type 'string' is not assignable to parameter of type '"GET" | "POST"'.
위 예시에서 req.method는 string으로 추론되지, "GET"으로 추론되지 않는다.
req의 생성 시점과 handleRequest의 호출 시점 사이에도 얼마든지 코드 평가가 발생할 수 있고, 이 때 req.method에 "GUESS"와 같은 새로운 문자열이 대입될 수도 있으므로, 위 코드에 오류가 있다고 판단한다.
이런 경우, 해결 방법에는 두 가지가 있다.
1. 둘 중 한 위치에 타입 단언을 추가해 추론 방식을 변경
// 수정 1:
const req = { url: "https://example.com", method: "GET" as "GET" };
// 수정 2
handleRequest(req.url, req.method as "GET");
2. as const를 사용해 객체 전체를 리터럴 타입으로 변환
const req = { url: "https://example.com", method: "GET" } as const;
handleRequest(req.url, req.method);
as const 접미사는 일반적인 const와 유사하게 작동하는데, 해당 객체의 모든 프로퍼티에 string 또는 number와 같은 보다 일반적인 타입이 아닌 리터럴 타입의 값이 대입되도록 보장한다.
null과 undefined
strictNullChecks가 설정되지 않았을 때
strictNullChecks가 설정되지 않았다면, 어떤 값이 null 또는 undefined일 수 있더라도 해당 값에 평소와 같이 접근할 수 있고, null과 undefined는 모든 타입의 변수에 대입될 수 있다. null 검사의 부재는 버그의 주요 원인이 되기도 하기 때문에 별다른 이유가 없다면 코드 전반에 걸쳐 strictNullChecks 옵션을 설정하는 것을 권장한다.
strictNullChecks가 설정되었을 때
strictNullChecks가 설정되었다면, 어떤 값이 null 또는 undefined일 때, 해당 값과 함께 메서드 또는 프로퍼티를 사용하기에 앞서 해당 값을 테스트해야 한다.
Null 아님 단언 연산자(접미사 !)
타입스크립트에서는 명시적인 검사를 하지 않고도 타입에서 null과 undefined를 제거할 수 있는 특별한 구문을 제공한다.
표현식 뒤에 !를 작성하면 해당 값이 null 또는 undefined가 아니라고 타입 단언하는 것이다.
function liveDangerously(x?: number | undefined) {
// 오류 없음
console.log(x!.toFixed());
}
! 연산자는 반드시 해당 값이 null 또는 undefined가 아닌 경우에만 사용해야 한다.
References
https://joshua1988.github.io/ts/why-ts.html
https://www.typescriptlang.org/ko/docs/handbook/2/everyday-types.html
'TIL' 카테고리의 다른 글
[TIL] 23.01.11 타입스크립트 핸드북 - More on Functions(1) (0) | 2023.01.12 |
---|---|
[TIL] 23.01.10 프리온보딩 챌린지를 시작하며 (0) | 2023.01.11 |
[TIL] 22.12.28 JWT 토큰 (0) | 2022.12.28 |
[TIL] 22.12.23 HTTP 헤더 - 캐시와 조건부 요청(2) (0) | 2022.12.23 |
[TIL] 22.12.21 HTTP 헤더 - 캐시와 조건부 요청(1) (0) | 2022.12.22 |