2.1 원시 타입
TypeScript의 타입 시스템은 JavaScript의 원시 값(primitive value) 위에 정적 타입 레이어를 더한 것에서 출발한다. 원시 타입(primitive type)은 객체가 아니며 메서드도 없는 가장 단순한 데이터 단위다. TypeScript 5.x 기준으로 string, number, boolean, null, undefined, symbol, bigint 일곱 가지가 존재한다. 이 장에서는 각 타입의 특성, 타입 어노테이션 작성 방법, null/undefined 안전 처리, 래퍼 객체를 피해야 하는 이유, 그리고 bigint·symbol의 실전 활용까지 깊이 있게 다룬다.
타입 어노테이션 — 명시적 vs 추론
TypeScript는 두 가지 방식으로 타입을 부여한다.
명시적 어노테이션: 변수나 매개변수 이름 뒤에 : 타입을 직접 작성한다.
const username: string = "Alice";
const age: number = 30;
const isAdmin: boolean = false;
타입 추론: 초기값이 있을 때 TypeScript 컴파일러가 값을 분석해 타입을 자동으로 결정한다.
const username = "Alice"; // 추론: string
const age = 30; // 추론: number
const isAdmin = false; // 추론: boolean
초기값이 명확할 때는 어노테이션을 생략해도 무방하다. 오히려 중복이 되어 코드 노이즈가 늘어난다. 반면 함수 매개변수, 나중에 값이 할당되는 변수, 반환 타입이 모호한 경우에는 명시적 어노테이션이 안전하다.
// 권장: 초기화 시점에 타입이 명확하면 추론에 맡긴다
const title = "TypeScript Guide";
// 권장: 나중에 할당하거나 null 가능성이 있을 때는 명시한다
let currentUser: string | null = null;
currentUser = "Bob";
// 권장: 함수 매개변수는 항상 명시한다
function greet(name: string): string {
return `Hello, ${name}`;
}
string
문자열은 작은따옴표('), 큰따옴표("), 백틱(`) 모두로 표현할 수 있다. TypeScript는 세 가지 형태를 동일한 string 타입으로 처리한다.
const firstName: string = "Alice";
const lastName: string = 'Wonderland';
const fullName: string = `${firstName} ${lastName}`; // 템플릿 리터럴
문자열 메서드와 타입 안전성
TypeScript는 string 타입 변수에 number 메서드를 호출하면 컴파일 단계에서 오류를 낸다.
const message = "hello world";
console.log(message.toUpperCase()); // "HELLO WORLD" — OK
console.log(message.toFixed(2)); // 오류: Property 'toFixed' does not exist on type 'string'
리터럴 타입
string 전체가 아닌 특정 문자열 값만 허용하는 문자열 리터럴 타입도 원시 타입 위에 세워진다.
type Direction = "north" | "south" | "east" | "west";
function move(dir: Direction): void {
console.log(`Moving ${dir}`);
}
move("north"); // OK
move("up"); // 오류: Argument of type '"up"' is not assignable to parameter of type 'Direction'
number
JavaScript와 마찬가지로 TypeScript의 number는 64비트 부동소수점(IEEE 754) 하나로 정수와 실수를 모두 표현한다. 정수 전용 타입이 별도로 없다는 점을 기억하자.
const integer: number = 42;
const float: number = 3.14159;
const negative: number = -100;
// 다양한 리터럴 표기법
const hex: number = 0xff; // 255 (16진수)
const octal: number = 0o377; // 255 (8진수)
const binary: number = 0b11111111; // 255 (2진수)
const million: number = 1_000_000; // 숫자 구분자 (ES2021)
특수값
number 타입에는 Infinity, -Infinity, NaN도 포함된다.
const inf: number = Infinity;
const notANumber: number = NaN;
// NaN 검사
function safeDivide(a: number, b: number): number {
if (b === 0) return NaN;
return a / b;
}
const result = safeDivide(10, 0);
if (Number.isNaN(result)) {
console.log("나눗셈 오류: 분모가 0입니다");
}
안전 정수 범위
Number.MAX_SAFE_INTEGER(2^53 - 1 = 9,007,199,254,740,991)를 초과하는 정수는 number 타입으로 정확히 표현할 수 없다. 금융이나 암호화 연산에서는 bigint를 사용해야 한다.
console.log(Number.MAX_SAFE_INTEGER); // 9007199254740991
console.log(Number.MAX_SAFE_INTEGER + 1); // 9007199254740992
console.log(Number.MAX_SAFE_INTEGER + 2); // 9007199254740992 — 오류! 같은 값
boolean
true 또는 false 두 값만 가지는 단순한 타입이다.
const isLoggedIn: boolean = true;
const hasPermission: boolean = false;
function checkAccess(isAdmin: boolean, isOwner: boolean): boolean {
return isAdmin || isOwner;
}
주의: truthy/falsy와 boolean의 차이
JavaScript의 암묵적 형변환에서 0, "", null, undefined, NaN은 falsy지만, TypeScript에서 이 값들은 boolean 타입이 아니다.
const value: boolean = 0; // 오류: Type 'number' is not assignable to type 'boolean'
const value2: boolean = !!0; // OK — 이중 부정으로 명시적 변환
const value3: boolean = Boolean(0); // OK
null과 undefined
null과 undefined는 각각 독립적인 타입이다.
undefined: 값이 할당되지 않은 상태null: 의도적으로 "값 없음"을 나타내는 상태
let u: undefined = undefined;
let n: null = null;
이 두 타입 자체만으로는 쓸 일이 거의 없다. 유니온 타입과 함께 "값이 있을 수도, 없을 수도 있다"는 상황을 표현할 때 진가를 발휘한다.
let userId: number | null = null; // 아직 로그인하지 않은 사용자
userId = 42; // 로그인 후 ID 할당
strictNullChecks
tsconfig.json에서 "strict": true 또는 "strictNullChecks": true를 설정하면, null과 undefined가 다른 타입에 자동으로 할당되지 않는다. 이 설정이 꺼져 있으면 모든 타입에 null과 undefined를 넣을 수 있어 런타임 오류의 주요 원인이 된다.
// strictNullChecks: true 환경
let name: string = "Alice";
name = null; // 오류: Type 'null' is not assignable to type 'string'
name = undefined; // 오류: Type 'undefined' is not assignable to type 'string'
// null을 허용하려면 유니온 타입으로 명시해야 한다
let nickname: string | null = null;
nickname = "Ally"; // OK
옵셔널 체이닝 (Optional Chaining)
?. 연산자를 사용하면 null 또는 undefined인 값에 접근할 때 오류 없이 undefined를 반환한다.
interface User {
id: number;
profile?: {
bio: string;
website?: string;
};
}
const user: User | null = null;
// 전통적 방식 — 장황하다
const bio = user !== null && user.profile !== undefined ? user.profile.bio : undefined;
// 옵셔널 체이닝 — 간결하다
const bio2 = user?.profile?.bio;
const website = user?.profile?.website; // undefined (오류 없음)
Nullish Coalescing (??)
?? 연산자는 좌항이 null 또는 undefined일 때만 우항의 기본값을 반환한다. || 연산자와 달리 0이나 ""처럼 falsy지만 유효한 값을 보존한다.
const count: number | null = 0;
const result1 = count || 10; // 10 — 잘못됨! 0은 유효한 값인데 기본값으로 덮어씀
const result2 = count ?? 10; // 0 — 올바름! null/undefined일 때만 기본값 사용
function getDisplayName(name: string | null | undefined): string {
return name ?? "익명 사용자";
}
console.log(getDisplayName(null)); // "익명 사용자"
console.log(getDisplayName(undefined)); // "익명 사용자"
console.log(getDisplayName("")); // "" — 빈 문자열은 유효한 이름으로 처리
console.log(getDisplayName("Alice")); // "Alice"
널 아님 단언 (Non-null Assertion)
컴파일러가 null 가능성을 의심하지만 개발자가 절대 null이 아님을 확신할 때 ! 연산자를 사용한다. 남용은 금물이다.
function processElement(id: string): void {
const el = document.getElementById(id);
// el은 HTMLElement | null 타입
// el!.textContent = "Hello"; // ! 사용 — 런타임에 null이면 오류 발생
// 더 안전한 방법: 타입 가드로 확인
if (el !== null) {
el.textContent = "Hello"; // 이 블록 안에서는 el이 HTMLElement로 좁혀짐
}
}
symbol
Symbol()로 생성되는 유일하고 불변인 값이다. 같은 설명 문자열을 써도 두 심볼은 절대 같지 않다.
const sym1 = Symbol("id");
const sym2 = Symbol("id");
console.log(sym1 === sym2); // false — 항상 유일
console.log(typeof sym1); // "symbol"
객체 고유 키로 활용
심볼을 객체 프로퍼티 키로 사용하면 외부에서 접근하거나 덮어쓸 수 없는 "숨겨진" 프로퍼티를 만들 수 있다.
const ID = Symbol("userId");
const SECRET = Symbol("secret");
interface UserRecord {
[ID]: number;
name: string;
}
const record: UserRecord = {
[ID]: 101,
name: "Alice",
};
console.log(record[ID]); // 101
console.log(record.name); // "Alice"
// Object.keys(record)는 [ID]를 반환하지 않는다 — 열거 불가
unique symbol
const 선언과 unique symbol 타입을 조합하면 타입 수준에서도 심볼의 유일성이 보장된다.
const TOKEN: unique symbol = Symbol("token");
type TokenType = typeof TOKEN;
// unique symbol은 같은 선언에서 만들어진 것끼리만 타입이 호환된다
const OTHER: unique symbol = Symbol("token");
// TOKEN === OTHER; // 오류: This condition will always return 'false' since the types have no overlap
Well-known Symbols
JavaScript 내장 동작을 커스터마이징하는 특별한 심볼들이다.
class Range {
constructor(private start: number, private end: number) {}
// for...of 루프 지원
[Symbol.iterator](): Iterator<number> {
let current = this.start;
const end = this.end;
return {
next(): IteratorResult<number> {
if (current <= end) {
return { value: current++, done: false };
}
return { value: 0, done: true };
},
};
}
}
const range = new Range(1, 5);
for (const n of range) {
process.stdout.write(n + " "); // 1 2 3 4 5
}
bigint
bigint는 Number.MAX_SAFE_INTEGER를 초과하는 정수를 정확하게 다루기 위한 타입이다. tsconfig.json에서 "target": "ES2020" 이상을 설정해야 한다.
const maxSafe: bigint = BigInt(Number.MAX_SAFE_INTEGER); // 9007199254740991n
const bigger: bigint = 9007199254740992n; // n 접미사 사용
const sum: bigint = maxSafe + 1n; // 9007199254740992n — 정확!
// number와 bigint는 직접 혼용 불가
// const mixed = maxSafe + 1; // 오류: Operator '+' cannot be applied to types 'bigint' and 'number'
const converted = maxSafe + BigInt(1); // BigInt()로 변환 후 연산
금융 계산 실전 예제
number는 부동소수점 오차가 발생할 수 있어 금융 계산에 부적합하다. 단위를 최소 단위(원화의 경우 1원, USD의 경우 1센트)로 올려 bigint로 처리한다.
// 금액을 원 단위 bigint로 관리
type KRW = bigint;
function addAmount(a: KRW, b: KRW): KRW {
return a + b;
}
function applyTax(amount: KRW, taxRateBps: bigint): KRW {
// taxRateBps: 세율 (basis points, 1% = 100bps)
return (amount * taxRateBps) / 10000n;
}
const price: KRW = 1_000_000n; // 100만원
const tax = applyTax(price, 1000n); // 10% 세금 = 100,000n
const total = addAmount(price, tax); // 1,100,000n
console.log(`총액: ${total.toLocaleString()}원`); // 총액: 1,100,000원
암호화 연산
RSA 등 공개키 암호화 알고리즘은 수백 자리 정수 연산이 필요하다. 실제 암호화 라이브러리(예: node-forge)는 내부적으로 bigint를 활용한다.
// 간단한 모듈러 지수 연산 예시 (실제 암호화는 라이브러리 사용 권장)
function modPow(base: bigint, exp: bigint, mod: bigint): bigint {
let result = 1n;
base = base % mod;
while (exp > 0n) {
if (exp % 2n === 1n) {
result = (result * base) % mod;
}
exp = exp / 2n;
base = (base * base) % mod;
}
return result;
}
// 공개키 지수 e=65537, 아주 작은 예시 mod
const message = 42n;
const e = 65537n;
const n = 3233n; // p=61, q=53 (실제로는 수백 비트)
const encrypted = modPow(message, e, n);
console.log(`암호화: ${encrypted}`);
래퍼 객체를 절대 사용하지 말아야 하는 이유
JavaScript에는 string, number, boolean 원시 타입에 대응하는 래퍼 객체인 String, Number, Boolean이 존재한다. TypeScript에서도 이 대문자 타입들을 어노테이션에 쓸 수 있지만, 절대로 사용해서는 안 된다.
타입 불일치 문제
// 잘못된 코드
const name1: String = "Alice"; // 래퍼 객체 타입 — 피해야 한다
const name2: string = new String("Alice"); // 오류! String은 string에 할당 불가
// 올바른 코드
const name3: string = "Alice"; // 원시 타입 — 항상 소문자 사용
string(원시 타입)은 String(객체 타입)에 할당 가능하지만, String은 string에 할당할 수 없다. 이 비대칭성이 예기치 않은 타입 오류를 유발한다.
동등 비교 실패
const a = new String("hello");
const b = new String("hello");
console.log(a === b); // false — 서로 다른 객체 참조
console.log(a == b); // false
console.log(a === "hello"); // false — 객체와 원시값은 다르다
console.log(a.valueOf() === "hello"); // true — valueOf()로만 원시값 추출 가능
원시 타입은 값으로 비교되지만, 래퍼 객체는 참조로 비교된다. 이로 인해 조건문 로직이 완전히 틀어질 수 있다.
메모리와 성능
래퍼 객체는 힙에 할당되는 반면 원시값은 스택이나 인라인으로 처리된다. 래퍼 객체를 대규모 배열에 저장하면 메모리 사용량과 GC 부담이 크게 증가한다.
// 나쁜 예 — 객체 배열
const badNames: String[] = [new String("Alice"), new String("Bob")];
// 좋은 예 — 원시값 배열
const goodNames: string[] = ["Alice", "Bob"];
ESLint 규칙 @typescript-eslint/ban-types는 기본적으로 String, Number, Boolean, Object, Symbol의 타입 어노테이션 사용을 금지한다.
실전 예제 1: 사용자 프로필 타입
// 원시 타입들로 구성된 사용자 프로필
interface UserProfile {
id: number;
username: string;
email: string;
age: number | null; // 선택적으로 제공되는 나이
isVerified: boolean;
createdAt: string; // ISO 8601 날짜 문자열
accountBalance: bigint; // 잔액 (원 단위, 정밀도 보장)
sessionToken: symbol | null; // 세션 토큰 (고유성 보장)
}
function createUser(username: string, email: string): UserProfile {
return {
id: Math.floor(Math.random() * 1_000_000),
username,
email,
age: null,
isVerified: false,
createdAt: new Date().toISOString(),
accountBalance: 0n,
sessionToken: null,
};
}
function login(user: UserProfile): UserProfile {
return {
...user,
sessionToken: Symbol(`session-${user.id}`),
};
}
function getDisplayAge(user: UserProfile): string {
return user.age ?? "나이 미입력";
}
// 사용
let user = createUser("alice", "alice@example.com");
user = login(user);
console.log(getDisplayAge(user)); // "나이 미입력"
user = { ...user, age: 28 };
console.log(getDisplayAge(user)); // "28"
실전 예제 2: 금액 계산기
type Currency = "KRW" | "USD" | "EUR";
interface Money {
amount: bigint; // 최소 단위 (KRW: 원, USD: 센트, EUR: 유로센트)
currency: Currency;
}
function createMoney(amount: bigint, currency: Currency): Money {
if (amount < 0n) throw new Error("금액은 음수일 수 없습니다");
return { amount, currency };
}
function addMoney(a: Money, b: Money): Money {
if (a.currency !== b.currency) {
throw new Error(`통화 불일치: ${a.currency} vs ${b.currency}`);
}
return { amount: a.amount + b.amount, currency: a.currency };
}
function formatMoney(money: Money): string {
const divisors: Record<Currency, bigint> = {
KRW: 1n,
USD: 100n,
EUR: 100n,
};
const symbols: Record<Currency, string> = {
KRW: "₩",
USD: "$",
EUR: "€",
};
const divisor = divisors[money.currency];
const whole = money.amount / divisor;
const fraction = money.amount % divisor;
if (divisor === 1n) {
return `${symbols[money.currency]}${whole.toLocaleString()}`;
}
return `${symbols[money.currency]}${whole}.${fraction.toString().padStart(2, "0")}`;
}
// 사용
const price = createMoney(5000n, "KRW"); // ₩5,000
const discount = createMoney(500n, "KRW"); // ₩500
const total = addMoney(price, discount);
console.log(formatMoney(total)); // ₩5,500
const usdPrice = createMoney(1299n, "USD"); // $12.99
console.log(formatMoney(usdPrice)); // $12.99
고수 팁
팁 1: 옵셔널 체이닝과 nullish coalescing을 체이닝하라
interface Config {
db?: {
host?: string;
port?: number;
};
}
function getDbHost(config: Config): string {
return config?.db?.host ?? "localhost";
}
function getDbPort(config: Config): number {
return config?.db?.port ?? 5432;
}
팁 2: 타입 가드 함수로 null 처리를 중앙화하라
function assertDefined<T>(value: T | null | undefined, label: string): T {
if (value === null || value === undefined) {
throw new Error(`${label}이(가) 정의되지 않았습니다`);
}
return value;
}
// 사용
const userId = assertDefined(getStoredUserId(), "userId");
// 이후 userId는 null/undefined 없는 타입으로 좁혀짐
팁 3: bigint 연산 유틸리티를 타입 별칭으로 추상화하라
type Cents = bigint; // USD 센트
type KRW = bigint; // 한국 원화
// 단위를 타입 별칭으로 구분하면 혼용 실수를 방지할 수 있다
// (실제 타입 수준 브랜딩은 Ch5 인터섹션 타입 참고)
팁 4: symbol을 모듈 간 충돌 없는 이벤트 키로 사용하라
// events.ts
export const USER_LOGGED_IN = Symbol("userLoggedIn");
export const USER_LOGGED_OUT = Symbol("userLoggedOut");
// auth.ts
import { USER_LOGGED_IN } from "./events";
const bus = new Map<symbol, Function[]>();
function emit(event: symbol, data: unknown): void {
bus.get(event)?.forEach((fn) => fn(data));
}
emit(USER_LOGGED_IN, { userId: 1 });
// Symbol 키라 문자열 오타 충돌 없음
팁 5: strictNullChecks는 반드시 켜라
tsconfig.json에서 "strict": true를 설정하는 것만으로 strictNullChecks가 활성화된다. 이 설정 없이는 TypeScript가 null 오류를 잡아내지 못해 런타임에서 Cannot read properties of null 오류가 빈번하게 발생한다.
정리 표
| 타입 | 예시 값 | 주요 용도 | 주의사항 |
|---|---|---|---|
string | "hello", `Hi` | 텍스트 데이터 | 래퍼 String 사용 금지 |
number | 42, 3.14, NaN | 일반 수치 연산 | 2^53 초과 시 bigint 사용 |
boolean | true, false | 논리값 | 0/"" 등 falsy 값과 구별 |
null | null | 의도적 값 없음 | strictNullChecks 활성화 필수 |
undefined | undefined | 미할당 상태 | ?? 연산자로 기본값 처리 |
symbol | Symbol("key") | 고유 키, 메타 프로그래밍 | 직렬화 불가 |
bigint | 9007199254740992n | 대규모 정수, 금융/암호화 | ES2020 타겟 필요, number 혼용 불가 |
다음 장에서는 any, unknown, never, void 네 가지 특수 타입을 살펴본다. 이 타입들은 원시 타입과 달리 타입 시스템의 경계를 다루는 개념으로, 특히 unknown과 never를 올바르게 활용하는 것이 안전한 TypeScript 코드의 핵심이다.