본문으로 건너뛰기
yutils

TypeScript 타입 시스템은 어떻게 동작할까?

structural typing · narrowing · discriminated union · generic · conditional · mapped type — TypeScript 가 컴파일 타임에 정확히 무엇을 검증하는지, runtime 값이 왜 여전히 surprise 줄 수 있는지, 타입 시스템 잘 쓰는 패턴.

약 9분 읽기

TypeScript 가 "type 안전" 을 보장한다지만 — runtime 에는 모든 type 정보가 사라진다. typeof foo === 'object' 가 실제 object 인지 어떻게 알까? interface 끼리 어떻게 호환되나? 그리고 conditional / mapped type 같은 advanced feature 가 왜 강력한가? 이 가이드는 structural typing, narrowing, generic, 그리고 runtime 의 한계를 정리한다.

Structural Typing — duck typing 의 강한 버전

Java / C# 의 nominal typing 과 다름. TypeScript 는 모양만 같으면 같은 type:

interface Point2D { x: number; y: number; }
interface Vector2D { x: number; y: number; }

const p: Point2D = {x: 1, y: 2};
const v: Vector2D = p;  // ✓ 호환 (구조 동일)

function distance(p: Point2D): number {
  return Math.sqrt(p.x ** 2 + p.y ** 2);
}

distance({x: 3, y: 4, z: 5});  // ✓ z 추가 OK (excess property 가 아닌 경우)
distance(v);                    // ✓ Vector2D 도 OK

"duck typing" — 오리처럼 걸으면 오리. 함수 인자도 마찬가지 — 필요한 field 만 있으면 통과.

Branded type — nominal 흉내

type UserId = string & {__brand: 'UserId'};
type PostId = string & {__brand: 'PostId'};

function getUser(id: UserId) { /* ... */ }

const a: UserId = '42' as UserId;
const b: PostId = '99' as PostId;

getUser(a);  // ✓
getUser(b);  // ✗ Type 'PostId' is not assignable to 'UserId'

같은 string 이지만 brand 로 구분. 단, as UserId 의 escape hatch 가 있으니 약속.

Type Narrowing — control flow analysis

function process(value: string | number) {
  if (typeof value === 'string') {
    value.toUpperCase();  // ✓ value 가 string 으로 narrowed
  } else {
    value.toFixed(2);     // ✓ value 가 number 로 narrowed
  }
}

TypeScript 가 control flow 추적. 가능한 narrowing 형식:

  • typeof value === 'string'
  • value instanceof Date
  • 'name' in object
  • value === null / value !== null
  • discriminated union (다음 절)
  • user-defined type guard (function isUser(x): x is User)

Discriminated Union — 가장 강력한 패턴

type Shape =
  | {kind: 'circle'; radius: number}
  | {kind: 'square'; size: number}
  | {kind: 'rectangle'; width: number; height: number};

function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
      // ↑ shape 가 {kind: 'circle', radius: number} 로 narrowed
    case 'square':
      return shape.size ** 2;
    case 'rectangle':
      return shape.width * shape.height;
  }
}

kind 같은 discriminator field 가 union 의 각 member 를 구분. switch 안에서 TypeScript 가 자동 narrow.

Exhaustiveness check

function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle': return /* ... */;
    case 'square': return /* ... */;
    // 'rectangle' 빠짐
    default:
      const _exhaustive: never = shape;  // ✗ 컴파일 에러
      return _exhaustive;
  }
}

// Shape 에 새 member 추가 → default 의 never assignment 가 에러
// → 모든 case 처리 강제

never 가 "도달 X" 타입. 새 case 추가 시 컴파일 에러로 알림.

Generic — type 의 함수

function identity<T>(value: T): T {
  return value;
}

identity<string>('hello');   // T = string
identity(42);                // T = number (자동 추론)

function head<T>(arr: T[]): T | undefined {
  return arr[0];
}

head([1, 2, 3]);             // number | undefined
head(['a', 'b']);            // string | undefined

Generic 의 constraint:

function longest<T extends {length: number}>(a: T, b: T): T {
  return a.length >= b.length ? a : b;
}

longest('hello', 'world');                    // ✓ string 의 length
longest([1, 2, 3], [4, 5]);                   // ✓ array 의 length
longest(42, 100);                             // ✗ number 에 length 없음

Conditional Types — type level 의 if/else

type IsString<T> = T extends string ? true : false;

type A = IsString<'hello'>;  // true
type B = IsString<42>;       // false

// 실용 — type level filtering
type NonNullable<T> = T extends null | undefined ? never : T;
type NN1 = NonNullable<string | null>;  // string
type NN2 = NonNullable<number | undefined | null>;  // number

// Built-in utility 들이 conditional 위에 build
type ReturnType<F> = F extends (...args: any) => infer R ? R : never;
type R = ReturnType<() => string>;  // string

infer 키워드가 type 안에서 type 추출. 강력하지만 복잡. 라이브러리 작성자가 자주, 일반 코드는 자제.

Mapped Types — type 의 map

type User = {
  id: string;
  name: string;
  email: string;
};

type Optional<T> = {
  [K in keyof T]?: T[K];   // 모든 field optional
};

type PartialUser = Optional<User>;
// = {id?: string; name?: string; email?: string}

type ReadOnly<T> = {
  readonly [K in keyof T]: T[K];
};

type Pick<T, K extends keyof T> = {
  [P in K]: T[P];
};

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

Partial, Required, Readonly,Pick, Omit, Record 모두 mapped type 위에 build. TypeScript 라이브러리의 핵심.

Runtime 의 한계 — type 은 사라진다

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

function isUser(x: unknown): x is User {
  return (
    typeof x === 'object' &&
    x !== null &&
    'id' in x &&
    'name' in x
  );
  // ↑ field 의 type 까지 검증 안 함. id가 number 여도 통과
}

// 안전한 검증 — Zod 같은 라이브러리
import {z} from 'zod';

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

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

const validated: User = UserSchema.parse(unknownInput);
// throws if invalid

TypeScript type 은 compile-time only. runtime 에는 사라짐. API 응답 / JSON.parse / 사용자 입력 — runtime 검증 필요. Zod / Yup / io-ts.

JSON → TypeScript 자동 생성

API 응답의 JSON 을 TypeScript interface 로:

// JSON
{
  "name": "Alice",
  "age": 30,
  "posts": [{"id": "1", "title": "Hello"}]
}

// 자동 생성된 TypeScript:
interface RootObject {
  name: string;
  age: number;
  posts: Post[];
}
interface Post {
  id: string;
  title: string;
}

JSON → TypeScript 가 정확히 이 동작. nested object 는 별도 interface 로 분리. API exploration 의 시작점.

any vs unknown vs never

  • any — type 검사 비활성. 무엇이든 OK. 무엇이든 호출 가능. type safety 포기
  • unknown — "모름" 표시. type narrowing 으로 좁히기 전 사용 불가
  • never — 절대 일어나지 않음 표시. exhaustive check / throw 함수 return
const a: any = 'hello';
a.toUpperCase();        // ✓
a.foo.bar.baz;          // ✓ (runtime crash)
a.nonExistent();        // ✓ (runtime crash)

const b: unknown = 'hello';
b.toUpperCase();        // ✗ Object is of type 'unknown'
if (typeof b === 'string') b.toUpperCase();  // ✓ narrowed

function fail(msg: string): never {
  throw new Error(msg);
}

any 피하고 unknown 권장. unknown 이 명시적 narrowing 강제.

흔한 함정

1. any 의 전염

const data: any = JSON.parse(rawString);
const name = data.user.name;  // 자동 any
const id = name.length;        // 또 any
// → 전체 코드의 type 안전 무너짐

// 대신
const data: unknown = JSON.parse(rawString);
const validated = UserSchema.parse(data);  // Zod

2. as casting 의 위험

const x = JSON.parse(input) as User;
// runtime 검증 X. 잘못된 JSON 이면 runtime crash 미궁

// 대신
const x = UserSchema.parse(JSON.parse(input));

3. ! (non-null assertion) 남발

const el = document.querySelector('.foo')!;  // null 가능성 무시
el.click();  // runtime null pointer 가능

// 대신
const el = document.querySelector('.foo');
if (!el) return;
el.click();

4. type 만 import vs runtime import

import {User} from './types';   // 둘 다 가능

// 명시적 — bundler 가 dead code elimination 쉬움
import type {User} from './types';

// 또는
import {type User, isUser} from './types';
// type 키워드가 User 만 type-only 표시

5. excess property check 의 함정

interface User { name: string; }

const u: User = {name: 'Alice', age: 30};  // ✗ excess 'age'

// 그러나 variable 거치면 통과
const obj = {name: 'Alice', age: 30};
const u2: User = obj;  // ✓ (structural)

// 보호 약함 — Zod 의 .strict() 로 runtime 검증

참고 자료

요약

  • TypeScript = structural typing (모양 매치). Java/C# 의 nominal 과 다름.
  • Narrowing — typeof / instanceof / in / discriminated union / type guard 로 union 좁힘.
  • Discriminated union + exhaustiveness check (never) 가 가장 강력한 패턴.
  • Generic = type 의 함수. constraint (extends) 로 제한.
  • Conditional / mapped type — Partial / Pick / Record 같은 utility 의 기반. 라이브러리 작성자 영역.
  • Type 은 compile-time 만 존재 — runtime 검증은 Zod / Yup.
  • any 피하고 unknown 권장. as casting / ! 남발 피함.
  • 실험 — JSON → TypeScript 로 API JSON → TypeScript interface 자동. JS Object → JSON 로 JS literal → JSON 변환.
가이드 목록으로