getUser({ userId: 1 })이라고 호출했는데 "/api/users/[object Object]" 요청이 날아갔습니다. 인자 타입을 강제하는 방법이 없을까요?

TypeScript는 자바스크립트에 정적 타입 시스템 을 추가해서, 이런 타입 관련 버그를 코드 작성 시점에 잡아줘요.


TypeScript가 왜 필요한가

JavaScript는 동적 타입 언어입니다. 변수에 아무 값이나 넣을 수 있고, 함수 인자 타입도 강제하지 않아요. 작은 프로젝트에서는 이게 오히려 편하지만, 코드가 수천 줄을 넘어가면 얘기가 달라집니다.

JS
function getUser(id) {
  // id가 number인지 string인지? 호출하는 쪽에서 뭘 넘길지 모른다
  return fetch(`/api/users/${id}`);
}

// 실수로 객체를 넘겨도 에러 없이 실행됨
getUser({ userId: 1 }); // "/api/users/[object Object]" 요청이 날아감

이런 버그는 테스트를 돌리거나 실제 배포 후에야 발견됩니다. TypeScript를 쓰면 이걸 코드 작성 시점에 잡을 수 있어요.

TS
function getUser(id: number): Promise<Response> {
  return fetch(`/api/users/${id}`);
}

getUser({ userId: 1 }); // 컴파일 에러! 'number' 타입에 객체를 넣을 수 없음

IDE 자동완성도 훨씬 강력해집니다. 타입 정보가 있으니까 객체의 프로퍼티나 함수의 반환값을 IDE가 정확히 알려줄 수 있어요. 협업할 때 "이 함수 뭐 반환해요?"라고 물어볼 필요가 없어지는 거죠.


기본 타입

TypeScript의 기본 타입들을 쭉 훑어보겠습니다.

TS
// 원시 타입
const name: string = "홍길동";
const age: number = 28;
const isActive: boolean = true;

// 배열
const scores: number[] = [90, 85, 92];
const names: Array<string> = ["Alice", "Bob"]; // 제네릭 방식

// 튜플 — 길이와 각 위치의 타입이 고정된 배열
const pair: [string, number] = ["age", 28];
// pair[0]은 string, pair[1]은 number로 추론됨

// enum — 관련된 상수들을 묶어서 관리
enum Direction {
  Up,    // 0
  Down,  // 1
  Left,  // 2
  Right, // 3
}

enum HttpStatus {
  OK = 200,
  NotFound = 404,
  InternalError = 500,
}

// void — 반환값이 없는 함수
function logMessage(msg: string): void {
  console.log(msg);
}

// null, undefined
let nullable: string | null = null;
let optional: string | undefined = undefined;

// never — 절대 발생하지 않는 값의 타입
function throwError(msg: string): never {
  throw new Error(msg);
}

function infiniteLoop(): never {
  while (true) {}
}

never는 좀 특이한데, 함수가 정상적으로 반환되지 않는 경우에 씁니다. 에러를 던지거나 무한 루프처럼 끝나지 않는 함수가 여기에 해당해요. 나중에 discriminated union에서 exhaustive check 용도로도 쓰이니까 기억해두면 좋습니다.


any vs unknown

둘 다 "아무 값이나 담을 수 있는 타입"이지만, 안전성에서 큰 차이가 있습니다.

any — 타입 검사 포기 선언

TS
let value: any = "hello";
value = 42;
value = { name: "test" };

// any는 아무 연산이나 허용한다
value.foo.bar.baz; // 에러 없음 — 런타임에 터짐
value();           // 에러 없음

any를 쓰면 TypeScript를 쓰는 의미가 사라집니다. 타입 검사를 완전히 우회하기 때문이에요. JS에서 TS로 마이그레이션하는 과도기에 임시로 쓰거나, 정말 타입을 알 수 없는 외부 라이브러리와의 접점에서만 제한적으로 써야 합니다.

unknown — 타입 안전한 any

TS
let value: unknown = "hello";
value = 42;
value = { name: "test" };

// unknown은 타입을 확인하기 전까지 연산을 허용하지 않음
value.foo;    // 컴파일 에러!
value();      // 컴파일 에러!

// 타입을 좁혀야 사용 가능
if (typeof value === "string") {
  console.log(value.toUpperCase()); // OK — 여기서 value는 string
}

if (value instanceof Error) {
  console.log(value.message); // OK
}

any와 unknown은 뭐가 다른 걸까요? 핵심만 정리하면 "둘 다 모든 타입을 받을 수 있지만, unknown은 타입을 좁히기 전까지 아무 연산도 할 수 없어서 타입 안전성을 보장한다" 는 점입니다.

실무에서는 외부 API 응답처럼 런타임에 타입이 결정되는 데이터를 받을 때 unknown을 쓰고, 타입 가드로 검증한 뒤 사용하는 패턴이 일반적입니다.


인터페이스 vs 타입

interface와 type은 뭐가 다른 걸까요? 둘 다 객체의 형태를 정의할 수 있는데, 미묘한 차이가 있습니다.

interface

TS
interface User {
  id: number;
  name: string;
  email?: string; // optional
}

// 선언 병합 (Declaration Merging) — interface만 가능
interface User {
  role: string;
}

// 위 두 선언이 합쳐져서 User는 id, name, email?, role을 가짐

// 확장
interface Admin extends User {
  permissions: string[];
}

type

TS
type User = {
  id: number;
  name: string;
  email?: string;
};

// 선언 병합 불가 — 같은 이름으로 다시 선언하면 에러
// type User = { role: string; }; // Error!

// 확장 — 교차 타입(&)으로
type Admin = User & {
  permissions: string[];
};

// type은 유니온, 튜플, 원시 타입 별칭도 가능
type ID = string | number;
type Pair = [string, number];
type Callback = (data: string) => void;

핵심 차이 정리

구분interfacetype
선언 병합가능불가
확장 방식extends& (교차 타입)
유니온 타입불가가능
튜플/원시 별칭불가가능
computed property불가가능 (in 키워드)

실무에서는 객체 형태를 정의할 때는 interface, 유니온이나 복잡한 타입 조합이 필요할 때는 type 을 쓰는 게 일반적입니다. 하지만 팀 컨벤션에 따라 "전부 type으로 통일"하는 곳도 꽤 있어요. 정답은 없고, 일관성이 중요합니다.


제네릭

제네릭은 타입을 파라미터로 받아서, 다양한 타입에 대해 재사용 가능한 코드를 만드는 기능입니다. TypeScript에서 가장 강력한 기능 중 하나예요.

함수 제네릭

TS
// 제네릭 없이 — any를 쓰면 타입 정보를 잃어버린다
function identity(value: any): any {
  return value;
}
const result = identity("hello"); // result 타입이 any ㅠㅠ

// 제네릭으로 — 타입 정보가 유지된다
function identity<T>(value: T): T {
  return value;
}
const result = identity("hello");    // result 타입이 string
const result2 = identity<number>(42); // 명시적으로 타입 지정도 가능

인터페이스/타입에 제네릭 적용

TS
interface ApiResponse<T> {
  status: number;
  data: T;
  message: string;
}

// 사용할 때 구체적인 타입을 넘긴다
type UserResponse = ApiResponse<User>;
type ProductListResponse = ApiResponse<Product[]>;

// 실무에서 API 호출 함수
async function fetchApi<T>(url: string): Promise<ApiResponse<T>> {
  const res = await fetch(url);
  return res.json();
}

const users = await fetchApi<User[]>("/api/users");
// users.data의 타입이 User[]로 추론됨

제약 조건 — extends

제네릭에 아무 타입이나 들어오면 곤란한 경우가 있습니다. extends로 제약을 걸 수 있어요.

TS
// T는 반드시 length 프로퍼티를 가진 타입이어야 함
function getLength<T extends { length: number }>(value: T): number {
  return value.length;
}

getLength("hello");     // OK — string에 length 있음
getLength([1, 2, 3]);   // OK — 배열에 length 있음
getLength(123);         // 에러! number에 length 없음
TS
// keyof와 조합 — 객체의 키만 허용
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { name: "홍길동", age: 28 };
getProperty(user, "name"); // 반환 타입 string
getProperty(user, "age");  // 반환 타입 number
getProperty(user, "foo");  // 에러! "foo"는 user의 키가 아님

keyof T는 T의 모든 키를 유니온 타입으로 만들어줍니다. 위 예시에서 keyof typeof user"name" | "age"가 돼요.


유틸리티 타입

TypeScript가 기본 제공하는 유틸리티 타입들입니다. 실무에서 안 쓸 수가 없어요.

Partial<T> — 모든 프로퍼티를 optional로

TS
interface User {
  id: number;
  name: string;
  email: string;
}

// 업데이트할 때 일부 필드만 보내고 싶을 때
function updateUser(id: number, updates: Partial<User>) {
  // updates의 모든 프로퍼티가 optional
}

updateUser(1, { name: "새이름" }); // email 안 보내도 OK

Required<T> — 모든 프로퍼티를 필수로

TS
interface Config {
  host?: string;
  port?: number;
  debug?: boolean;
}

// 최종적으로 사용할 때는 모든 값이 채워져 있어야 함
const finalConfig: Required<Config> = {
  host: "localhost",
  port: 3000,
  debug: false,
};

Pick<T, K> — 특정 프로퍼티만 뽑기

TS
type UserPreview = Pick<User, "id" | "name">;
// { id: number; name: string; }

Omit<T, K> — 특정 프로퍼티 제외

TS
type CreateUserDto = Omit<User, "id">;
// { name: string; email: string; } — id는 서버에서 생성하니까

Record<K, V> — 키-값 쌍의 타입 정의

TS
type Role = "admin" | "user" | "guest";

const rolePermissions: Record<Role, string[]> = {
  admin: ["read", "write", "delete"],
  user: ["read", "write"],
  guest: ["read"],
};

Readonly<T> — 모든 프로퍼티를 읽기 전용으로

TS
const config: Readonly<Config> = { host: "localhost", port: 3000 };
config.port = 8080; // 에러! 읽기 전용 프로퍼티에 할당 불가

ReturnType<T> — 함수의 반환 타입 추출

TS
function createUser() {
  return { id: 1, name: "test", createdAt: new Date() };
}

type CreatedUser = ReturnType<typeof createUser>;
// { id: number; name: string; createdAt: Date; }

라이브러리 함수의 반환 타입을 직접 타이핑하기 귀찮을 때 유용합니다. 함수 구현이 바뀌면 타입도 자동으로 따라가요.


타입 가드

TypeScript에서 타입을 좁히는 방법들입니다. 런타임 검사를 통해 컴파일러에게 "이 시점에서 이 변수는 이 타입이야"라고 알려주는 거예요.

typeof

TS
function process(value: string | number) {
  if (typeof value === "string") {
    // 여기서 value는 string
    console.log(value.toUpperCase());
  } else {
    // 여기서 value는 number
    console.log(value.toFixed(2));
  }
}

instanceof

TS
class ApiError extends Error {
  statusCode: number;
  constructor(message: string, statusCode: number) {
    super(message);
    this.statusCode = statusCode;
  }
}

function handleError(error: Error | ApiError) {
  if (error instanceof ApiError) {
    // ApiError로 좁혀짐
    console.log(`HTTP ${error.statusCode}: ${error.message}`);
  } else {
    console.log(error.message);
  }
}

in 연산자

TS
interface Bird {
  fly(): void;
  layEggs(): void;
}

interface Fish {
  swim(): void;
  layEggs(): void;
}

function move(animal: Bird | Fish) {
  if ("fly" in animal) {
    animal.fly(); // Bird로 좁혀짐
  } else {
    animal.swim(); // Fish로 좁혀짐
  }
}

사용자 정의 타입 가드 (is)

위의 방법들로 충분하지 않을 때, 직접 타입 가드 함수를 만들 수 있습니다.

TS
interface Car {
  type: "car";
  drive(): void;
}

interface Bicycle {
  type: "bicycle";
  pedal(): void;
}

// 반환 타입이 "vehicle is Car" — 이게 핵심
function isCar(vehicle: Car | Bicycle): vehicle is Car {
  return vehicle.type === "car";
}

function useVehicle(vehicle: Car | Bicycle) {
  if (isCar(vehicle)) {
    vehicle.drive(); // Car로 좁혀짐
  } else {
    vehicle.pedal(); // Bicycle로 좁혀짐
  }
}

is 키워드가 없으면 단순히 boolean을 반환하는 함수일 뿐이고, 컴파일러는 타입을 좁혀주지 않습니다. is를 붙여야 TypeScript가 "이 함수가 true를 반환하면 해당 변수는 이 타입이다"라는 걸 인식해요.


타입 좁히기 (Narrowing)

타입 가드를 포함한 더 넓은 개념입니다. 분기문이나 조건을 통해 타입의 범위를 점점 좁혀나가는 거예요.

분기문을 통한 Narrowing

TS
function printId(id: string | number | null) {
  if (id === null) {
    console.log("No ID");
    return;
  }
  // 여기서 id는 string | number (null이 제거됨)

  if (typeof id === "string") {
    console.log(id.toUpperCase());
  } else {
    console.log(id.toFixed(0));
  }
}

Discriminated Union (구별된 유니온)

실무에서 정말 많이 쓰는 패턴입니다. 각 타입에 공통된 리터럴 프로퍼티를 두고, 그 값으로 분기해요.

TS
interface LoadingState {
  status: "loading";
}

interface SuccessState {
  status: "success";
  data: string[];
}

interface ErrorState {
  status: "error";
  error: string;
}

type RequestState = LoadingState | SuccessState | ErrorState;

function render(state: RequestState) {
  switch (state.status) {
    case "loading":
      return "로딩 중...";
    case "success":
      return state.data.join(", "); // data 접근 가능
    case "error":
      return `에러: ${state.error}`; // error 접근 가능
  }
}

status 프로퍼티가 discriminant(구별자) 역할을 합니다. 각 case에서 TypeScript가 정확히 어떤 타입인지 알고 있기 때문에, 해당 타입에만 있는 프로퍼티에 안전하게 접근할 수 있어요.

never를 이용한 Exhaustive Check

새로운 상태를 추가했는데 switch문에서 처리를 안 했을 때, 컴파일 에러를 내주는 패턴입니다.

TS
function render(state: RequestState): string {
  switch (state.status) {
    case "loading":
      return "로딩 중...";
    case "success":
      return state.data.join(", ");
    case "error":
      return `에러: ${state.error}`;
    default:
      // 모든 케이스를 처리했다면 state는 never 타입
      const _exhaustive: never = state;
      return _exhaustive;
  }
}

만약 RequestState에 새로운 타입을 추가하고 case를 안 넣으면, default에서 never에 할당할 수 없다는 컴파일 에러가 발생합니다. 이걸로 빠뜨린 케이스를 컴파일 타임에 잡을 수 있어요.


Mapped Types와 Conditional Types

고급 타입 조작인데, 유틸리티 타입들이 내부적으로 이걸 씁니다. 원리를 알면 커스텀 유틸리티 타입을 만들 수 있어요.

Mapped Types

기존 타입의 각 프로퍼티를 순회하면서 새로운 타입을 만드는 방식입니다.

TS
// Partial<T>의 실제 구현
type MyPartial<T> = {
  [K in keyof T]?: T[K];
};

// Readonly<T>의 실제 구현
type MyReadonly<T> = {
  readonly [K in keyof T]: T[K];
};

// 모든 프로퍼티를 nullable로 만드는 커스텀 유틸리티
type Nullable<T> = {
  [K in keyof T]: T[K] | null;
};

interface User {
  id: number;
  name: string;
}

type NullableUser = Nullable<User>;
// { id: number | null; name: string | null; }

keyof T로 키를 뽑고, in으로 순회하고, T[K]로 해당 키의 타입에 접근하는 구조입니다.

Conditional Types

삼항 연산자처럼 조건에 따라 타입을 결정하는 방식입니다.

TS
// T가 string을 확장하면 "yes", 아니면 "no"
type IsString<T> = T extends string ? "yes" : "no";

type A = IsString<string>;  // "yes"
type B = IsString<number>;  // "no"

infer 키워드와 조합하면 타입에서 특정 부분을 추출할 수 있습니다.

TS
// 배열 요소의 타입을 꺼내기
type ElementType<T> = T extends (infer U)[] ? U : never;

type A = ElementType<string[]>;  // string
type B = ElementType<number[]>;  // number
type C = ElementType<string>;    // never — 배열이 아니니까

// 함수의 첫 번째 인자 타입 꺼내기
type FirstArg<T> = T extends (first: infer F, ...args: any[]) => any ? F : never;

type D = FirstArg<(name: string, age: number) => void>; // string

ReturnType<T>도 내부적으로 conditional type + infer로 구현되어 있습니다.

TS
// ReturnType의 실제 구현
type MyReturnType<T extends (...args: any) => any> =
  T extends (...args: any) => infer R ? R : any;

심화 주제

구조적 타이핑 (Structural Typing / Duck Typing)

TypeScript는 명목적 타이핑(Nominal Typing)이 아니라 구조적 타이핑을 사용합니다. 타입의 이름이 아니라, 구조(프로퍼티)가 호환되는지를 봐요.

TS
interface Point {
  x: number;
  y: number;
}

interface Coordinate {
  x: number;
  y: number;
}

const p: Point = { x: 1, y: 2 };
const c: Coordinate = p; // OK! 구조가 같으니까

이름이 PointCoordinate든 상관없습니다. x: number, y: number를 가지고 있으면 호환돼요. 이게 "오리처럼 걷고 꽥꽥거리면 오리다"라는 Duck Typing의 TypeScript 버전입니다.

선언 파일 (.d.ts)

.d.ts 파일은 타입 정보만 담고 있는 파일입니다. 구현 코드 없이 타입 선언만 있어요.

TS
// types.d.ts
declare module "some-library" {
  export function doSomething(value: string): number;
  export interface Config {
    timeout: number;
    retries: number;
  }
}

주요 쓰임새는 이렇습니다.

  • **JavaScript 라이브러리에 타입 붙이기 **: @types/react, @types/node 같은 DefinitelyTyped 패키지가 이거예요.
  • ** 전역 타입 선언 **: 프로젝트 전체에서 쓰는 타입을 선언할 때 사용합니다.
  • ** 모듈 타입 보강 **: 기존 모듈에 타입을 추가하거나 수정할 때 씁니다.

strict mode 옵션들

tsconfig.jsonstrict: true는 여러 엄격한 타입 검사 옵션을 한번에 켜는 겁니다.

옵션설명
strictNullChecksnull/undefined를 다른 타입에 할당 불가
strictFunctionTypes함수 타입 호환성을 엄격하게 검사
strictBindCallApplybind, call, apply의 인자 타입 검사
strictPropertyInitialization클래스 프로퍼티 초기화 강제
noImplicitAny암묵적 any 타입을 허용하지 않음
noImplicitThisthis의 타입이 any인 경우 에러
alwaysStrict모든 파일에 "use strict" 적용

신규 프로젝트라면 무조건 strict: true로 시작해야 합니다. 나중에 켜려면 에러가 수백 개씩 쏟아져서 고치기 훨씬 힘들어져요.

enum vs const enum vs as const

이 세 가지는 어떻게 다른 걸까요?

TS
// 1. enum — 런타임에 객체로 존재함
enum Direction {
  Up = "UP",
  Down = "DOWN",
}
// 컴파일 후 JavaScript에 Direction 객체가 남아있음

// 2. const enum — 컴파일 시 인라인으로 치환됨
const enum Color {
  Red = "RED",
  Blue = "BLUE",
}
const c = Color.Red; // 컴파일 후: const c = "RED"
// Color 객체 자체는 사라지므로 번들 크기가 줄어듦

// 3. as const — 리터럴 타입으로 좁혀줌
const ROLES = {
  ADMIN: "admin",
  USER: "user",
  GUEST: "guest",
} as const;

type Role = typeof ROLES[keyof typeof ROLES];
// "admin" | "user" | "guest"

최근 트렌드는 enum 대신 as const를 선호하는 쪽입니다. 이유는 몇 가지가 있어요.

  • enum은 TypeScript 고유 문법이라 JS와의 경계가 모호해집니다.
  • const enum--isolatedModules 옵션과 호환 문제가 있습니다 (Babel, esbuild 등에서).
  • as const는 순수 JavaScript 객체 + 타입 추론이라 어디서든 자연스럽게 동작해요.

파생 개념

여기서 다룬 TypeScript 개념들은 다른 주제들과 긴밀하게 연결됩니다.

  • JavaScript: TypeScript는 JavaScript의 슈퍼셋입니다. 클로저, 프로토타입, 이벤트 루프 같은 JS 핵심 개념 위에 타입 시스템이 얹어지는 구조이므로, JS를 제대로 이해하는 게 선행되어야 해요.

  • React + TypeScript: 컴포넌트 props 타입 정의, 이벤트 핸들러 타입, 제네릭을 활용한 커스텀 훅 등 React에서 TypeScript가 쓰이는 지점이 많습니다. React.FC, React.ComponentProps, React.ChangeEvent<HTMLInputElement> 같은 타입들을 알아야 해요.

  • Next.js: App Router의 page.tsx에서 params 타입 정의, API Route의 요청/응답 타입, getServerSideProps의 반환 타입 추론 등 Next.js 고유의 타입 패턴이 존재합니다.


마무리

TypeScript의 타입 시스템은 결국 "런타임에 터질 버그를 컴파일 타임에 잡자"라는 하나의 목표를 향해 설계되어 있습니다. 기본 타입부터 시작해서 제네릭, 유틸리티 타입, 타입 가드, conditional type까지 전부 이 목표의 연장선이에요.

단순히 문법을 아는 것보다, 왜 이런 기능이 필요한지를 이해하는 게 중요합니다. "Partial은 모든 프로퍼티를 optional로 만듭니다"보다 "업데이트 API에서 변경된 필드만 보내야 할 때 Partial을 쓰면 타입 안전하게 처리할 수 있습니다"처럼, 실무 맥락과 연결지어 답할 수 있으면 좋아요.

댓글 로딩 중...