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 objectvalue === 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 | undefinedGeneric 의 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>; // stringinfer 키워드가 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 invalidTypeScript 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); // Zod2. 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 Handbook — typescriptlang.org
- Type Challenges (advanced exercises) — GitHub
- Zod — runtime validation — zod.dev
- Effective TypeScript (Dan Vanderkam) — effectivetypescript.com
요약
- 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 변환.