[TypeScript] 타입스크립트 기본 타입 정리

들어가며
타입스크립트 교과서 2장을 공부하면서 정리한 내용입니다. 타입 표기부터 제네릭까지, 타입스크립트의 기본 타입 시스템을 다룹니다.
1. 타입 표기와 타입 추론
변수 이름 뒤에 : 타입 을 붙여서 타입을 표기합니다.
let hello: string = "Hello, TypeScript!";
function add(x: number, y: number): number {
return x + y;
}
매개변수에 타입을 표기하지 않으면 any 타입이 되고, Parameter 'x' implicitly has an 'any' type. 에러가 납니다. 이를 implicit any 에러라고 합니다.
그렇다고 모든 변수에 타입을 붙여야 하는 건 아닙니다. 책 저자의 원칙은 다음과 같습니다.
타입스크립트가 타입을 제대로 추론하면 그대로 쓰고, 틀리게 추론할 때만 올바른 타입을 표기한다.
const 와 let 의 추론 차이
const 로 선언한 값은 바뀔 일이 없으니 리터럴 타입으로 추론됩니다.
const str = "hello"; // "hello"
const num = 123; // 123
const bool = false; // false
반면 let 은 값이 바뀔 수 있으므로 더 넓은 타입으로 추론합니다. 이를 타입 넓히기(Type Widening) 라고 합니다.
let str = "hello"; // string
let num = 123; // number
let bool = false; // boolean
몇 가지 특이한 점도 있습니다.
- 객체는
const로 선언해도 안의 값이 바뀔 수 있으므로{ hello: string }처럼 속성은 넓은 타입으로 추론됩니다. -
let변수에null,undefined를 대입하면any로 추론됩니다. -
const로 선언한 심볼은 각각unique symbol타입이 되어,sym1 === sym2처럼 비교하면 타입 에러가 납니다. (JS에서는 비교 가능)
@ts-ignore 와 @ts-expect-error
-
// @ts-ignore: 다음 줄의 타입 에러를 무시합니다. -
// @ts-expect-error: 다음 줄에 반드시 에러가 있어야 합니다. 에러가 없으면 오히려 에러가 납니다.
둘 다 안 쓰는 게 좋지만, 굳이 써야 한다면 의도를 드러내는 @ts-expect-error 를 쓰는 편이 낫습니다.
2. 리터럴 타입과 as const
값 자체를 타입으로 쓸 수 있습니다.
let str: "hello" = "hello";
// str = "123"; // 에러
const obj: { name: "zero" } = { name: "zero" };
const func: (amount: number, unit: string) => string = (amount, unit) => amount + unit;
함수 타입에서는 반환값을 : 가 아니라 => 로 표기한다는 점에 주의해야 합니다.
일일이 리터럴 타입을 적는 대신 as const 를 붙이면 리터럴 타입 + 읽기 전용으로 추론됩니다.
const obj1 = { name: "zero" };
obj1.name = "123"; // 가능
const obj2 = { name: "zero" } as const;
// obj2.name = "123"; // 에러
const arr = [1, 3, "five"] as const; // readonly [1, 3, "five"]
// arr.push(1); // 에러
const 는 변수의 재대입을 막고, readonly 는 요소 변경까지 막습니다. 다만 readonly 는 타입 검사에서만 적용되고, 실행 중에 배열을 실제로 얼리지는 않습니다.
3. 배열과 튜플
배열은 두 가지 방식으로 선언할 수 있습니다.
const arr1: string[] = ["1", "2", "3"];
const arr2: Array<number> = [1, 2, 3];
const arr3 = [1, 2, 3]; // number[]
const arr4 = [1, "2", 3]; // (string | number)[]
const arr5 = []; // any[]
JS 배열은 없는 인덱스에 접근해도 그냥 undefined 가 나옵니다. 각 위치의 타입과 길이까지 정하고 싶다면 튜플을 사용합니다.
const tuple: [number, boolean, string] = [1, false, "hi"];
// tuple[3] = "no"; // 에러: 없는 요소
tuple.push("4"); // 이건 됨...
튜플이어도 push 는 막히지 않습니다. 수정까지 막으려면 readonly 를 붙여야 합니다.
const tuple: readonly [number, boolean, string] = [1, false, "hi"];
전개 문법으로 가변 길이 튜플도 만들 수 있습니다.
const a: [string, number, ...boolean[]] = ["hi", 1, true, false];
const b: [string, ...number[], boolean] = ["hi", 1, 2, 3, false];
const c: [...string[], number, boolean] = ["hi", "1", "2", 3, false];
4. 타입으로 쓸 수 있는 값
Date, Math 처럼 내장 객체는 타입으로도 쓸 수 있습니다.
String, Number, Boolean 같은 래퍼 객체도 타입으로 쓸 수는 있지만, Number + Number 가 안 되고 string 에 String 을 대입할 수 없는 등 불편한 점이 많습니다. 소문자 string, number 를 쓰는 것이 좋습니다.
함수의 타입을 그대로 가져오고 싶다면 typeof 를 사용합니다.
function add(x: number, y: number) {
return x + y;
}
const add2: typeof add = (x, y) => x + y;
5. 유니언 타입과 타입 좁히기
| 로 “A 또는 B” 를 표현합니다.
function returnNumber(value: string | number) {
if (typeof value === "string") {
return parseInt(value); // 여기서 value는 string
}
return value; // 여기서 value는 number
}
parseInt 는 string 만 받기 때문에, typeof 검사로 타입을 좁혀줘야 합니다. 이를 타입 좁히기(Type Narrowing) 라고 합니다.
6. 타입스크립트에만 있는 타입
any
모든 타입을 허용합니다. 사실상 타입 검사를 포기한다는 선언이라 지양해야 합니다.
let str: any = "hello";
const result = str.toFixed(); // string인데 toFixed가 됨. result도 any
any 에서 파생된 값도 전부 any 가 됩니다. 타입스크립트가 any 로 추론하는 곳이 있다면 직접 타입을 표기해 주어야 합니다.
unknown
any 처럼 모든 값을 대입할 수 있지만, 대입한 후에는 아무 동작도 할 수 없습니다.
try {
} catch (e) {
// console.log(e.message); // 에러: e는 unknown
const error = e as Error;
console.log(error.message);
}
이럴 때 as 로 타입을 명시하는 것을 타입 단언(Type Assertion) 이라고 합니다. <Error>e 형태도 있지만 JSX 문법과 충돌하므로 as 를 추천합니다.
void
반환값이 없는 함수의 반환 타입입니다. JS에서는 반환값이 없으면 undefined 가 반환되지만, TS에서는 타입이 void 가 됩니다.
재미있는 점은 함수 타입에 void 를 쓰면 실제로 값을 반환해도 에러가 나지 않는다는 것입니다.
const func: () => void = () => 3; // OK
const value = func(); // 실제 값은 3이지만 타입은 void
// const func2 = (): void => 3; // 에러
() => void 타입은 “반환값을 신경 쓰지 않겠다” 는 뜻이라 반환값이 있어도 허용됩니다. 반면 함수 선언에 직접 : void 를 붙이면 반환값이 있을 때 에러가 납니다.
{} 와 object
{} 타입은 객체가 아니라 null, undefined 를 제외한 모든 값을 의미합니다.
const a: {} = "hello";
const b: {} = 123;
const c: {} = { key: "value" };
// const d: {} = null; // 에러
다만 대입만 될 뿐, 실제로 사용하려 하면 c.key 처럼 속성 접근이 막힙니다. {} 에 null, undefined 까지 더하면 unknown 과 같아집니다.
never
절대 발생하지 않는 값의 타입입니다. 어떤 값도 대입할 수 없습니다. 주로 정상 종료되지 않는 함수에 씁니다.
function fail(message: string): never {
throw new Error(message);
}
가능한 타입을 전부 제거해도 never 가 남습니다.
function check(value: string | number) {
if (typeof value === "string") return;
if (typeof value === "number") return;
const remaining: never = value; // 남은 타입이 없음
}
undefined 와 null
둘 다 “값이 없다” 를 나타내지만 의도가 다릅니다.
-
undefined: 아직 값이 정해지지 않은 상태 -
null: 의도적으로 비워둔 상태
7. 타입 별칭과 인터페이스
타입 별칭
type 으로 기존 타입에 이름을 붙입니다. 주로 복잡하거나 가독성이 낮은 타입에 사용합니다.
type ValueWithUnit = (value: number, unit: string) => string;
const func: ValueWithUnit = (value, unit) => value + unit;
인터페이스
객체 타입에 이름을 붙이는 또 하나의 방법입니다.
interface Person {
name: string;
age: number;
married: boolean;
}
interface Func {
(x: number, y: number): number;
}
인덱스 시그니처로 “키가 number 인 속성” 을 표현할 수도 있습니다.
interface Arr {
length: number;
[key: number]: string;
}
const arr: Arr = ["2", "3", "4"]; // OK
배열은 length 와 숫자 키를 가지고 있으니 대입이 됩니다. 하지만 Arr 에 정의되지 않은 push, map 같은 메서드는 사용할 수 없습니다.
선언 병합
같은 이름의 인터페이스를 여러 번 선언하면 하나로 합쳐집니다. 이를 선언 병합(Declaration Merging) 이라고 합니다.
interface Merge { one: string; }
interface Merge { two: number; }
const example: Merge = { one: "1", two: 2 };
Java, C# 에서는 다른 사람이 만든 인터페이스를 수정할 수 없지만, TS는 이렇게 확장을 허용해 줍니다.
네임스페이스
반대로 남이 만든 인터페이스와 의도치 않게 병합될 위험도 있습니다. 이럴 때 네임스페이스로 이름을 감쌀 수 있습니다. 밖에서 쓰려면 export 해야 하고, 네임스페이스도 이름이 겹치면 병합됩니다.
namespace Example {
export interface Inner {
test: string;
}
}
const ex: Example.Inner = { test: "hello" };
8. 객체의 속성
초과 속성 검사
인터페이스는 “해당 속성을 포함해야 한다” 는 조건입니다. 그런데 객체 리터럴을 바로 대입하면 정의되지 않은 속성도 검사합니다.
interface Example {
hello: string;
}
// const example: Example = { hello: "hi", why: "나는 에러" }; // 에러
const obj = { hello: "hi", why: "나는 에러 아님" };
const example2: Example = obj; // OK
인덱스 접근 타입
다른 타입의 속성 타입을 그대로 가져올 수 있습니다. 원본 타입이 바뀌면 자동으로 따라 바뀌어서 관리가 편합니다.
type Animal = { name: string };
type NameType = Animal["name"]; // string
9. 타입을 집합으로 생각하기
-
|(유니언) : 합집합 -
&(인터섹션) : 교집합 -
unknown: 전체 집합 -
never: 공집합
string & number 는 “string 이면서 number 인 값” 이라 존재하지 않으므로 never 가 됩니다.
대입은 항상 좁은 타입에서 넓은 타입으로만 가능합니다. 그리고 any 는 이 집합 규칙에서 예외입니다.
10. 타입 상속
인터페이스는 extends, 타입 별칭은 & 로 상속합니다.
interface Animal {
name: string;
}
interface Dog extends Animal {
bark(): void;
}
type Cat = Animal & {
meow(): void;
};
자식에서 부모의 속성 타입을 바꿀 수도 있지만, 더 좁은 타입으로만 바꿀 수 있습니다.
11. 구조적 타이핑
TS는 이름이 달라도 구조가 같으면 같은 타입으로 취급합니다. 이를 구조적 타이핑(Structural Typing) 이라고 합니다.
interface Money { amount: number; unit: string; }
interface Liter { amount: number; unit: string; }
const liter: Liter = { amount: 1, unit: "liter" };
const money: Money = liter; // OK
C# 이나 Java 처럼 이름으로 타입을 구분하는 언어에 익숙하면 처음에 헷갈리는 부분입니다.
12. 제네릭
중복되는 타입을 함수처럼 매개변수화할 수 있습니다.
interface Person<N, A> {
type: "human";
race: "yellow";
name: N;
age: A;
}
interface Zero extends Person<"zero", 28> {}
interface Nero extends Person<"nero", 32> {}
함수에서는 선언문이냐 표현식이냐에 따라 제네릭 위치가 다르니 주의해야 합니다.
function personFactory<N, A>(name: N, age: A) {
return { type: "human", race: "yellow", name, age };
}
const personFactoryE = <N, A>(name: N, age: A) => ({
type: "human", race: "yellow", name, age,
});
기본값과 제약
// 기본값
interface Person<N = string, A = number> { /* ... */ }
// 제약 (C#의 where 와 비슷)
interface Example<A extends number, B = string> {
a: A;
b: B;
}
// type U1 = Example<string, boolean>; // 에러
type U2 = Example<1>;
자주 쓰는 제약은 다음과 같습니다.
| 제약 | 의미 |
|---|---|
<T extends object> |
모든 객체 |
<T extends any[]> |
모든 배열 |
<T extends (...args: any) => any> |
모든 함수 |
<T extends abstract new (...args: any) => any> |
모든 생성자 |
<T extends keyof any> |
string | number | symbol |
13. 함수
나머지 매개변수
function example(a: string, ...b: number[]) {}
example("hi", 1, 2, 3);
콜백 함수의 매개변수
인수로 넘기는 콜백 함수의 매개변수에는 타입을 표기하지 않아도 됩니다. 문맥상 추론되기 때문입니다(문맥적 추론).
function example(callback: (error: Error, result: string) => void) {}
example((e, r) => {}); // e: Error, r: string
example(() => {}); // 안 쓰는 매개변수는 생략 가능
example(() => true); // 반환 타입이 void라 반환값이 있어도 OK
안 쓸 매개변수라고 옵셔널(?)로 만드는 건 좋지 않습니다. 타입이 | undefined 가 되어 의도와 다르게 동작할 수 있기 때문입니다.
14. 타입 가드
is 키워드로 직접 타입 좁히기 함수를 만들 수 있습니다.
interface A {
name: string;
}
function isA(value: unknown): value is A {
return typeof value === "object" && value !== null && "name" in value;
}
isA(x) 가 true 이면, 그 블록 안에서 x 는 A 타입으로 취급됩니다.
마치며
C# 을 쓰다가 타입스크립트를 보니, 비슷해 보여도 구조적 타이핑과 타입을 집합으로 보는 관점이 가장 큰 차이였습니다. 2장 뒷부분(조건부 타입, infer, 템플릿 리터럴 타입 등)은 따로 정리해서 올리겠습니다.