FrontEnd

TypeScript 기본 타입, 실제 코드에서 찾아보기

TypeScript 기본 타입(리터럴, 튜플, enum, unknown, never 등)을 KOIN 웹 코드에서 실제로 쓰인 곳과 함께 정리했습니다.

프론트 스터디를 하며 TypeScript 강의 (기본 타입부터 void와 never까지)을 듣고 내가 이해한 점과 KOIN 웹 프론트엔드 코드에서 각 개념이 실제로 쓰인 곳을 하나씩 찾아봤다.

원시 타입과 리터럴 타입

원시 타입은 number, string, boolean, null, undefined다. 리터럴 타입은 값 하나를 그대로 타입으로 쓰는 것이다.

const num1: 10 = 10;      // 리터럴 타입: 10만 들어갈 수 있다
const num2: number = 20;  // 이건 리터럴이 아니라 원시 타입

처음엔 리터럴 타입을 “문자 타입을 지정하는 것”으로 적었는데, 정확히는 값 자체가 타입이 되는 것이다.

KOIN에서는 utils/auth/session.ts에 둘 다 나온다.

export type UserType = 'STUDENT' | 'GENERAL';

export type Session =
  | { status: 'authenticated'; userType: UserType }
  | { status: 'anonymous' };

export const toUserType = (value: string | null | undefined): UserType | null => { ... };
  • UserType을 그냥 string으로 두면 'STUDNET' 같은 오타도 통과한다. 리터럴 유니온으로 막아 두면 허용된 값 외에는 컴파일 에러가 난다.
  • Session은 status 값으로 두 모양을 구분한다. status === 'authenticated'를 확인한 뒤에야 userType을 꺼낼 수 있다.
  • toUserType은 세션 API 응답의 user_type처럼 값이 없을 수도 있는 입력을 null | undefined로 열어 두고, 반환에서는 “모르면 null”로 하나로 좁힌다.

튜플

튜플은 길이와 인덱스별 타입이 고정된 배열이다. JS로 컴파일하면 그냥 배열이라 push, pop 메서드가 가능해 사용시 주의가 필요하다. 인덱스마다 들어갈 값이 정해져 있을 때 쓴다.

components/Articles/utils/setArticleRegisteredDate.ts는 게시글 날짜 문자열과 NEW 뱃지 여부를 튜플로 돌려준다.

const setArticleRegisteredDate = (registeredAt: string, currentDate: Date | null): [string, boolean] => {
  const formattedDate = convertDate(registeredAt);
  const isNew = currentDate ? isNewArticle(registeredAt, currentDate) : false;
  return [formattedDate, isNew];
};

커스텀 훅들은 as const를 붙여 읽기 전용 튜플을 돌려준다(useLocalStorage, useBooleanState 등).

return [value, setValue] as const;

useState가 [값, setter]를 돌려주는 것과 같은 패턴이고, readonly라서 앞에서 걱정한 push/pop 문제도 막힌다.

객체, 타입 별칭, 인덱스 시그니처

객체를 Object로만 선언하면 TypeScript는 “객체라는 것”만 알고 안의 속성은 모른다. 그래서 obj.id에 접근할 수 없다. 속성까지 적어야 한다.

const obj: Object = { id: 1, name: '홍길동' };                   // obj.id → 에러
const obj2: { id: number; name: string } = { id: 1, name: '홍길동' }; // OK

이 모양을 매번 쓸 수는 없으니 이름을 붙이는 게 type과 interface다. KOIN에서는 둘을 이렇게 나눠 쓴다.

  • type: 앞의 Session처럼 유니온(A | B) 이 필요할 때. 유니온은 interface로 만들 수 없다.
  • interface: api/articles/entity.ts의 PaginatedResponse<T> extends PaginationInfo, APIResponse처럼 응답 모양을 확장(extends) 해서 쌓을 때.

인덱스 시그니처는 키 이름을 미리 정하지 않고 “문자열 키면 이 타입”이라고 열어 두는 문법이다. 같은 파일의 SearchArticlesRequest가 그 예다.

export interface SearchArticlesRequest {
  [key: string]: unknown;
  query: string;
  boardId?: number;
  page?: number;
  limit?: number;
}

query, boardId는 정해진 키고, 그 밖의 문자열 키도 허용한다.

enum: KOIN에는 하나도 없다

enum은 관련된 상수들에 이름을 붙여 하나로 묶는 TypeScript 문법이다.

enum Role {
  ADMIN = 'ADMIN',
  USER = 'USER',
}

const role: Role = Role.ADMIN;

이 코드를 컴파일하면 JS에 이런 객체가 생긴다.

var Role;
(function (Role) {
  Role["ADMIN"] = "ADMIN";
  Role["USER"] = "USER";
})(Role || (Role = {}));

그래서 enum은 타입이면서 동시에 런타임에 존재하는 값(객체)이다.

type, interface, string 같은 대부분의 타입 표기는 컴파일하면 사라지지만, enum은 JS 객체로 남는다. KOIN 코드에서 enum을 검색해 보니 선언이 0건이었다. 대신 이렇게 쓴다.

interfaces/APIRequest.ts

export const HTTP_METHOD = {
  GET: 'GET',
  POST: 'POST',
  PATCH: 'PATCH',
  PUT: 'PUT',
  DELETE: 'DELETE',
} as const;

export type HTTPMethod = (typeof HTTP_METHOD)[keyof typeof HTTP_METHOD];
// 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
  • 값이 필요할 때는 평범한 객체 HTTP_METHOD.GET을 쓴다.
  • 타입이 필요할 때는 그 객체에서 뽑아낸 리터럴 유니온 HTTPMethod를 쓴다.

어차피 런타임에 객체가 남는 건 enum과 같다. 다만 TypeScript 전용 문법 대신 평범한 JS 객체와 리터럴 타입만으로 같은 효과를 낸다.

any와 unknown

둘 다 모든 타입을 받는다. 차이는 받은 다음이다. any는 어디에나 그대로 넣을 수 있지만, unknown은 정제하기 전에는 다른 곳에 쓸 수 없다.

any는 린트로 막혀 있다

KOIN에서 any는 거의 안 보인다. 드물게 쓸 때는 이렇게 사유를 남긴다(static/routes.ts).

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ROUTES 함수마다 params 타입이 달라 목록 원소 타입을 any로 통일한다.
type RouteFn = (params: any) => string;

“남용하면 TypeScript의 장점이 사라진다”는 말을 팀 규칙(ESLint no-explicit-any)으로 강제하는 셈이다.

unknown은 에러 처리에서 쓴다

catch로 잡은 에러는 무엇이 던져졌는지 모른다. 그래서 unknown으로 받고 하나씩 좁힌다(utils/ts/httpError.ts).

export function getErrorStatus(error: unknown): number | undefined {
  if (isKoinError(error)) return error.status;
  if (typeof error === 'object' && error !== null && 'response' in error) {
    const { response } = error as { response?: { status?: unknown } };
    if (typeof response?.status === 'number') return response.status;
  }
  return undefined;
}
  1. isKoinError: KOIN 에러 모양이면 바로 status를 꺼낸다.
  2. typeof error === 'object' && error !== null: 객체인지 확인한다(typeof null도 'object'라서 null을 따로 뺀다).
  3. 'response' in error: response 속성이 있는지 확인한다.
  4. typeof response?.status === 'number': 마지막으로 숫자인지 확인한 뒤에야 반환한다.

강의의 typeof x === 'number' 한 줄이 실제로는 이렇게 여러 단계로 쌓인다.

void와 never

void는 “반환값이 없다(쓰지 않는다)”, never는 “어떤 값도 들어갈 수 없다”는 뜻이다.

void: 콜백 props

KOIN에서 void는 대부분 props로 넘기는 이벤트 핸들러에 나온다(utils/hooks/ui/useToastTimer.ts 등).

onClose: () => void;
onOutsideClick: (e: MouseEvent) => void;

호출하는 쪽이 반환값을 쓰지 않는다는 약속이다.

never: 속성 자리에도 쓴다

강의에서는 무한루프나 throw new Error()처럼 함수 반환 예제로 배웠다. 그런데 KOIN에서는 속성 타입으로 쓰고 있었다(utils/hooks/analytics/useLogger.ts).

type LoggerEventName =
  | { team: LoggingTeam; event_name?: never }   // ① team만
  | { team?: never; event_name: string };       // ② event_name만

GA 이벤트명이 team에서 event_name으로 바뀌는 중이라, 둘 중 하나만 쓰라는 규칙을 컴파일러가 강제하게 만든 타입이다.

never는 입력에도 쓰인다

이 타입에 { team: 'CAMPUS', event_name: 'DA1' }처럼 둘 다 넣으면 어떻게 될까?

정답은 컴파일 에러다.

핵심은 “그 어떤 값도 넣을 수 없다”였다. never는 반환 전용이 아니라 값이 하나도 없는 타입이다. 반환 자리에 두면 “반환이 일어날 수 없다”가 되고, 속성 자리에 두면 “여기엔 아무 값도 넣을 수 없다”가 된다.

TypeScript는 유니온의 각 모양에 하나씩 대입해 본다.

넣은 값①에 대입②에 대입결과
team만통과team이 never라 실패성공
event_name만event_name이 never라 실패통과성공
둘 다event_name이 never라 실패team이 never라 실패실패
둘 다 없음필수인 team이 없어 실패필수인 event_name이 없어 실패실패

?:(선택 속성)가 붙어 있어서 “아예 안 쓰는 것”(undefined)은 허용된다. 그래서 이 타입은 결국 “정확히 하나만” 을 강제한다. 리뷰에서 사람이 잡을 실수를 타입이 미리 막아 주는 것이다.

마치며

강의 예제와 실제 코드를 나란히 놓고 보니 같은 개념도 쓰임새가 달랐다.

개념강의에서 배운 것KOIN에서 본 것
리터럴 타입const a: 10 = 10'STUDENT' | 'GENERAL' 같은 유니온
튜플길이·타입 고정 배열훅의 [value, setValue] as const
enum컴파일 후에도 객체로 남음아예 안 쓰고 as const 객체로 대체
unknowntypeof로 정제에러를 여러 단계로 좁힘
never함수 반환(무한루프, throw)속성에 넣어 “정확히 하나만” 강제

강의만 들었을 때는 다 아는 것 같았는데, 실제 코드에서 대입해서 보니 바로 이해하지 못했던 코드가 보였다. 실제 코드에서 타입은 단순한 표기가 아니라, 사람이 리뷰에서 잡을 실수를 미리 막는 규칙으로 쓰이고 있었다.

댓글

질문과 경험을 댓글로 나눠주세요. 댓글은 GitHub 계정으로 작성할 수 있습니다.