FEInterview Prep

typescript

TypeScript 타입 시스템: 구조적 타이핑, 제네릭, 조건부 타입, 유틸리티 타입

7년차 개발자는 TypeScript를 "쓸 줄 아는" 수준을 넘어, 구조적 타이핑의 의미, 제네릭의 원리, 조건부 타입과 infer의 동작 방식, 유틸리티 타입 구현 원리를 설명할 수 있어야 합니다. any를 쓰는 대신 unknown을 써야 하는 이유, never 타입이 왜 필요한지도 중요합니다.

시작 전
이해도
매우 낮음

학습 개요

탄생 배경

해결하려 했던 문제

JavaScript는 동적 타입 언어로, 타입 오류가 런타임에 발생합니다. 대규모 팀에서 코드베이스가 커질수록 함수의 인자 타입, 반환 타입을 문서나 기억에 의존해야 하며 리팩토링이 위험했습니다. TypeScript는 컴파일 타임에 타입 오류를 잡아내고, IDE의 자동완성과 리팩토링 도구를 강력하게 지원합니다.

역사적 맥락

2012년 Microsoft가 TypeScript 발표 → 초기에는 Angular.js 팀이 채택 → 2016년 Angular 2가 TypeScript 공식 채택으로 폭발적 성장 → 2019년 React 생태계에서 TypeScript 표준화 → 2020년 Deno가 TypeScript 네이티브 지원 → 2022년 대부분의 주요 오픈소스 라이브러리가 TypeScript 지원 → 2023년 TypeScript "Type-Only" 단계적 제안(TC39)

이전에는 어떻게 했나

Flow(Facebook 개발): TypeScript의 경쟁 타입 시스템으로 등장했지만 생태계 경쟁에서 TypeScript에 졌습니다. JSDoc으로 타입 힌트를 제공하는 방법도 있지만 기능이 제한적입니다.

멘탈 모델

동작 원리

[구조적 타이핑 (Structural Typing)] TypeScript는 이름이 아닌 구조로 타입을 비교합니다. 자바(명목적 타이핑)와 근본적으로 다릅니다. 명목적 타이핑: 같은 이름의 타입만 호환 (Java, C#) 구조적 타이핑: 같은 구조(속성과 타입)이면 호환 interface Dog { name: string; bark(): void; } interface Cat { name: string; bark(): void; } // TypeScript에서 Dog와 Cat은 구조가 같으므로 호환됨 (놀라운 점!) [제네릭이 필요한 이유] 함수가 다양한 타입을 받지만 타입 정보를 유지하고 싶을 때 사용. any: 타입 정보를 모두 잃어버림 제네릭: 입력 타입에 따라 출력 타입이 결정됨 function identity<T>(arg: T): T { return arg; } const num = identity(42); // T = number, 반환 타입 = number const str = identity('hello'); // T = string, 반환 타입 = string [조건부 타입과 infer] 타입 레벨에서의 if-else. T extends U ? X : Y: T가 U에 할당 가능하면 X, 아니면 Y. infer: 조건부 타입 안에서 타입을 추론하여 새로운 타입 변수로 캡처. // 함수의 반환 타입 추출 type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never; type GetUserReturn = ReturnType<typeof getUser>; // User // 프로미스의 내부 타입 추출 type Awaited<T> = T extends Promise<infer U> ? U : T; type PromiseValue = Awaited<Promise<string>>; // string // 배열 요소 타입 추출 type ElementType<T> = T extends (infer E)[] ? E : never; type StrElement = ElementType<string[]>; // string [유틸리티 타입 구현 원리] Partial<T>: 모든 속성을 optional로 type Partial<T> = { [P in keyof T]?: T[P] }; Required<T>: 모든 속성을 required로 type Required<T> = { [P in keyof T]-?: T[P] }; Pick<T, K>: 특정 속성만 선택 type Pick<T, K extends keyof T> = { [P in K]: T[P] }; Omit<T, K>: 특정 속성 제외 type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>; Exclude<T, U>: T에서 U에 할당 가능한 타입 제거 (유니온에서) type Exclude<T, U> = T extends U ? never : T; type A = Exclude<'a' | 'b' | 'c', 'a' | 'b'>; // 'c' [타입 가드의 종류] 1. typeof: 원시 타입 구분 if (typeof x === 'string') { /* x는 string */ } 2. instanceof: 클래스 인스턴스 구분 if (error instanceof NetworkError) { /* NetworkError 속성 접근 */ } 3. in 연산자: 속성 존재 여부 if ('name' in obj) { /* obj.name 존재 */ } 4. 사용자 정의 타입 가드 (is 키워드): function isUser(obj: unknown): obj is User { return typeof obj === 'object' && obj !== null && 'name' in obj; } 5. Discriminated Union (태그된 유니온): type Shape = | { kind: 'circle'; radius: number } | { kind: 'square'; side: number }; function area(shape: Shape) { switch (shape.kind) { case 'circle': return Math.PI * shape.radius ** 2; case 'square': return shape.side ** 2; // TypeScript가 exhaustive check를 통해 누락된 케이스 감지 } } [never 타입이 필요한 이유] never는 절대 발생하지 않는 타입입니다. 1. Exhaustive Check: 모든 케이스를 처리했는지 컴파일 타임에 검증 2. 무한 루프 함수의 반환 타입 3. throw만 하는 함수의 반환 타입 4. 유니온에서 특정 타입 제거 (Exclude 구현) [enum vs const as const] enum은 런타임에 JavaScript 객체를 생성합니다. as const는 런타임 영향 없이 타입만 좁힙니다. const Direction = { UP: 'UP', DOWN: 'DOWN' } as const; type Direction = typeof Direction[keyof typeof Direction]; // 'UP' | 'DOWN' → 런타임 비용 없음, Tree Shaking 가능, enum보다 권장

핵심 구성 요소

구조적 타이핑

이름이 아닌 구조로 타입 호환성 결정. JavaScript 생태계에 자연스럽게 통합

제네릭

타입을 파라미터화하여 재사용 가능한 컴포넌트 구현

조건부 타입

T extends U ? X : Y 형태의 타입 레벨 조건문

infer

조건부 타입에서 타입을 추론하여 새 변수로 캡처

매핑된 타입

[P in keyof T]로 기존 타입의 각 속성을 변환

never

절대 발생하지 않는 타입. Exhaustive check와 타입 연산에 사용

흐름 설명


[Discriminated Union + Exhaustive Check 패턴]
type ApiState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

function handleState<T>(state: ApiState<T>) {
  switch (state.status) {
    case 'idle': return null;
    case 'loading': return <Spinner />;
    case 'success': return <Display data={state.data} />; // data가 안전하게 접근 가능
    case 'error': return <Error message={state.error.message} />;
    default:
      // 이 시점에서 state는 never 타입
      // 새로운 status가 추가되면 TypeScript가 이 에러를 잡아줌
      const exhaustiveCheck: never = state;
      throw new Error('처리되지 않은 상태: ' + exhaustiveCheck);
  }
}

[런타임 타입 검증: Zod]
// TypeScript의 타입은 컴파일 타임에만 존재 → 런타임 검증 불가
// 외부 데이터(API 응답, form input)는 런타임 검증 필요

import { z } from 'zod';

const UserSchema = z.object({
  id: z.number(),
  name: z.string().min(1),
  email: z.string().email(),
});

type User = z.infer<typeof UserSchema>; // TypeScript 타입 자동 생성

// API 응답 검증
const data = await fetch('/api/user').then(res => res.json());
const user = UserSchema.parse(data); // 실패 시 throw
// 또는
const result = UserSchema.safeParse(data); // 실패해도 throw 없음
if (result.success) { /* result.data: User */ }
    

코드 예제


// 제네릭으로 재사용 가능한 API 훅
function useApi<T>(url: string) {
  const [state, setState] = useState<ApiState<T>>({ status: 'idle' });

  const fetch = useCallback(async () => {
    setState({ status: 'loading' });
    try {
      const data = await fetchJson<T>(url);
      setState({ status: 'success', data });
    } catch (error) {
      setState({ status: 'error', error: error as Error });
    }
  }, [url]);

  return { ...state, fetch };
}

// 조건부 타입 - 복잡한 타입 추론
type DeepPartial<T> = T extends object
  ? { [P in keyof T]?: DeepPartial<T[P]> }
  : T;

// 함수 오버로드 타입 추론
type UnwrapPromise<T> = T extends Promise<infer U>
  ? UnwrapPromise<U>  // 중첩 Promise도 처리
  : T;

type A = UnwrapPromise<Promise<Promise<string>>>; // string

// Template Literal Types (TypeScript 4.1+)
type EventName = 'click' | 'focus' | 'blur';
type HandlerName = `on${Capitalize<EventName>}`; // 'onClick' | 'onFocus' | 'onBlur'

// Mapped Types + Conditional Types 조합
type NonNullableProperties<T> = {
  [P in keyof T]: NonNullable<T[P]>;
};

// unknown vs any
function processData(data: unknown) {
  // unknown은 사용 전에 타입 검증 필요
  if (typeof data === 'string') {
    return data.toUpperCase(); // OK
  }
  // return data.toUpperCase(); // Error! unknown에 메서드 호출 불가
}

function processDataAny(data: any) {
  return data.toUpperCase(); // 타입 체크 없이 통과 → 런타임 에러 가능!
}

// const as const vs enum
// enum (런타임 코드 생성)
enum Status { Active = 'ACTIVE', Inactive = 'INACTIVE' }
// 컴파일 결과: var Status; Status['Active'] = 'ACTIVE'; ...

// as const (런타임 코드 없음)
const Status = { Active: 'ACTIVE', Inactive: 'INACTIVE' } as const;
type Status = typeof Status[keyof typeof Status]; // 'ACTIVE' | 'INACTIVE'
// 컴파일 결과: const Status = { Active: 'ACTIVE', Inactive: 'INACTIVE' };
    

비교 분석

TypeScript

vs

명목적 타이핑 (Nominal Typing, Java/C#)

비교 관점
이 방식
명목적 타이핑 (Nominal Typing, Java/C#)
타입 호환성
구조적 타이핑: 구조가 같으면 다른 이름이어도 호환
명목적 타이핑: 같은 이름(또는 명시적 상속)이어야 호환
유연성
구조적 타이핑: 덕 타이핑 철학 (오리처럼 걷고 오리처럼 울면 오리)
명목적 타이핑: 명시적 타입 선언 필요, 더 엄격
JavaScript 통합
구조적 타이핑: JavaScript 생태계와 자연스럽게 통합
명목적 타이핑: 인터페이스/클래스를 모두 명시적으로 구현해야 함
브랜딩 패턴
구조적 타이핑: 브랜드 타입으로 명목적 타이핑 흉내 가능
명목적 타이핑: 기본 동작

TypeScript

vs

unknown vs any

비교 관점
이 방식
unknown vs any
타입 안전성
unknown: 사용 전 타입 좁히기(narrowing) 필수
any: 모든 타입 체크 우회 (사실상 TypeScript 끄기)
할당 가능성
unknown: 다른 타입에 직접 할당 불가 (검증 후 가능)
any: 모든 타입에 할당 가능
런타임 안전성
unknown: 컴파일 타임에 위험한 코드 감지
any: 런타임에서야 오류 발생
사용 시점
unknown: 타입을 모르는 외부 데이터 처리
any: 타입 정의가 없는 레거시 JS 마이그레이션 (임시)

트레이드오프

이상적인 사용 사례

면접 질문