LabHub

블로그

TypeScript 타입 시스템의 깊이 — 구조적 타이핑, Generic, Conditional, satisfies, tsc Go 포팅, Zod, tRPC까지 (2025)

한국어English日本語

"TypeScript was a humble goal: add types to JavaScript. Ten years later, it's become one of the most sophisticated practical type systems ever shipped." — Anders Hejlsberg (C# 수석 아키텍트, TypeScript 창시자)

2012년 10월 1일, Microsoft가 "JavaScript for Application-scale Development"라는 이름으로 TypeScript를 공개했다. 당시 반응은 미지근했다. "또 Microsoft가 자바스크립트를 지배하려는 시도"라는 냉소가 많았다.

13년이 지난 2025년, Stack Overflow 조사에서 TypeScript가 가장 사랑받는 언어 3위다. React, Vue, Angular, Next.js 등 주요 프레임워크가 기본 언어로 채택했고, GitHub 신규 JS 리포의 70%가 TypeScript로 시작한다. Deno와 Bun은 타입스크립트를 네이티브 실행한다.

이 글은 ".ts 파일을 쓴다"에서 "TypeScript의 깊이를 이해한다"로 넘어가려는 사람을 위한 지도다.


1. Gradual Typing — 성공의 공식

왜 다른 시도들은 실패했나

TypeScript가 살아남은 이유:

  1. 점진적 도입 — 한 파일씩 마이그레이션 가능
  2. JavaScript 슈퍼셋 — 모든 JS가 유효한 TS
  3. 컴파일 후 순수 JS — 런타임 의존성 제로
  4. 강력한 추론 — 타입 애너테이션 최소화
  5. 거대한 커뮤니티 — DefinitelyTyped 17만+ 패키지

"Soundness를 포기했기에 성공했다"


2. 구조적 타이핑 (Structural Typing)

Nominal vs Structural

Nominal (Java, C#): 타입의 "이름"이 일치해야

class Point { int x, y; }
class Vec2 { int x, y; }

Point p = new Vec2();  // 에러! 다른 클래스

Structural (TypeScript, Go): 모양(shape)이 일치하면 OK

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

const p: Point = { x: 1, y: 2 }  // OK
const v: Vec2 = p                 // OK, 같은 모양

Duck Typing의 정적 버전

"오리처럼 걷고 오리처럼 울면 오리다" — 런타임에 검사하는 파이썬의 방식을 컴파일 시간에 한다.

장점

단점 — Branded Types의 필요

type UserId = string
type PostId = string

function getUser(id: UserId) { ... }
const postId: PostId = 'p123'
getUser(postId)  // 구조가 같아서 OK... 버그!

해결:

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

const userId = 'u1' as UserId
const postId = 'p1' as PostId
getUser(postId)  // 에러!

2024년 TypeScript 5.6에서 Branded Types를 공식 지원 패턴으로 문서화.


3. 추론의 예술 — Type Inference

리터럴 추론

let x = 'hello'      // string
const y = 'hello'    // "hello" (리터럴)
const z = { a: 1 }   // { a: number }  -- readonly 아님

const값 자체를 타입으로, let넓힌 타입.

as const — 완전 불변

const config = {
  mode: 'production',
  debug: false,
} as const
// { readonly mode: "production"; readonly debug: false }

함수 추론

function map<T, U>(arr: T[], fn: (x: T) => U): U[] { ... }

const nums = [1, 2, 3]
map(nums, x => x * 2)  // T=number, U=number 자동 추론

제약 있는 추론

function pick<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key]
}

const user = { name: 'Kim', age: 20 }
pick(user, 'name')  // string (T[K] = T['name'] = string)

4. Generic — 함수의 추상화

기본 제네릭

function identity<T>(x: T): T { return x }
const n = identity(42)       // number
const s = identity('hello')  // string

제약 (Constraint)

interface HasLength { length: number }

function logLength<T extends HasLength>(x: T): T {
  console.log(x.length)
  return x
}

logLength('hello')     // OK
logLength([1,2,3])     // OK
logLength(123)         // 에러 (length 없음)

기본값

type Page<T = any> = {
  items: T[]
  total: number
}

type StringPage = Page<string>
type DefaultPage = Page  // Page<any>

Variance — 공변성과 반공변성

type Printer<T> = (x: T) => void

let animalPrinter: Printer<Animal>
let dogPrinter: Printer<Dog>

animalPrinter = dogPrinter  // 에러 (Printer는 반공변)
dogPrinter = animalPrinter  // OK

함수 인자는 반공변, 리턴은 공변. TypeScript는 기본 이변(bivariant)지만 strictFunctionTypes에서 올바름.


5. Conditional Types — 타입 수준의 if

기본 문법

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

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

infer — 타입 추출

type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never

function getUser() { return { id: 1, name: 'Kim' } }
type User = ReturnType<typeof getUser>  // { id: number; name: string }

TypeScript 표준 ReturnType, Parameters, Awaited 모두 이 메커니즘.

Distributive Conditional Types

type ToArray<T> = T extends any ? T[] : never

type A = ToArray<string | number>
// string[] | number[]  (Union에 분배됨)

Union type이 자동으로 순회됨.

실전 — 유용한 조합

// Truthy만 추출
type NonNullable<T> = T extends null | undefined ? never : T

// 함수가 아닌 것만
type NonFunction<T> = T extends Function ? never : T

// Promise unwrap
type Unwrap<T> = T extends Promise<infer U> ? U : T

6. Mapped Types — 객체 변환의 힘

기본

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

type User = { name: string; age: number }
type ReadonlyUser = Readonly<User>
// { readonly name: string; readonly age: number }

변경자 — +?, -?, +readonly, -readonly

type Required<T> = { [K in keyof T]-?: T[K] }  // ? 제거
type Mutable<T> = { -readonly [K in keyof T]: T[K] }  // readonly 제거

Key Remapping (TS 4.1+)

type Getters<T> = {
  [K in keyof T as `get${Capitalize<K & string>}`]: () => T[K]
}

type User = { name: string; age: number }
type UserGetters = Getters<User>
// { getName: () => string; getAge: () => number }

실전 — DeepPartial

type DeepPartial<T> = T extends object
  ? { [K in keyof T]?: DeepPartial<T[K]> }
  : T

재귀 + 조건부 + 맵드 타입의 조합.


7. Template Literal Types (TS 4.1, 2020)

기본

type Greeting = `Hello, ${string}`
const g: Greeting = 'Hello, World'  // OK
const bad: Greeting = 'Hi, World'  // 에러

조합

type Color = 'red' | 'blue'
type Size = 'sm' | 'lg'
type Class = `${Color}-${Size}`
// "red-sm" | "red-lg" | "blue-sm" | "blue-lg"

파싱 — infer 와 결합

type ExtractRoute<T> = 
  T extends `/users/${infer Id}/posts/${infer PostId}`
    ? { id: Id; postId: PostId }
    : never

type Route = ExtractRoute<'/users/123/posts/456'>
// { id: "123"; postId: "456" }

이 패턴으로 Router 타입 안전성(Next.js, Remix가 내부적으로 활용).

유명한 활용 — Tailwind IntelliSense

text-red-500, bg-blue-200 같은 수천 개 클래스를 타입으로 표현. Template literal types가 없었으면 불가능.


8. satisfies — 2022년의 작은 혁명

문제

type Config = { [key: string]: string | number }

const config = {
  name: 'app',
  port: 3000,
} as Config

config.name.toUpperCase()  // 에러! string | number라서

as로 캐스팅하면 구체 타입 정보를 잃음.

satisfies 해결 (TS 4.9)

const config = {
  name: 'app',
  port: 3000,
} satisfies Config

config.name.toUpperCase()  // OK, string으로 추론
config.port.toFixed(2)     // OK, number로 추론

"이 타입을 만족하는지 검사하되, 구체 타입은 유지하라."

실전 예

type Themes = Record<string, { primary: string; secondary: string }>

const themes = {
  light: { primary: '#fff', secondary: '#000' },
  dark: { primary: '#000', secondary: '#fff' },
} satisfies Themes

// themes.light.primary -- "#fff" (리터럴 타입 유지!)
// themes.unknown       -- 에러 (정의 안 됨)

as Themes로 하면 string이 되지만, satisfies로는 리터럴을 유지. 상수 객체에서 특히 유용.


9. 타입 수준 프로그래밍 — Type Challenges

GitHub의 type-challenges/type-challenges는 TypeScript 타입만으로 풀어야 하는 문제 모음. 예:

구현: Pick

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

구현: DeepReadonly

type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object 
    ? DeepReadonly<T[K]> 
    : T[K]
}

구현: TupleToObject

type TupleToObject<T extends readonly string[]> = {
  [K in T[number]]: K
}

type Result = TupleToObject<['a', 'b']>
// { a: 'a'; b: 'b' }

왜 가치 있는가

단, 프로덕션에서 과도한 타입 프로그래밍은 피하라 — 컴파일 속도와 가독성 저하.


10. 모듈 해석의 복잡성

CommonJS vs ESM

// CommonJS
const mod = require('./foo')
module.exports = { ... }

// ESM
import mod from './foo'
export default { ... }

Node.js가 두 시스템을 모두 지원하며, 이를 TypeScript가 중개하는 것은 지옥이다.

module 옵션

moduleResolution

2024년 현실


11. tsc의 한계와 Go 포팅 (2025)

tsc의 문제

대안들

Anders의 충격 발표 (2025년 3월)

"We are porting the TypeScript compiler to Go."

Why Go, Not Rust?

의미


12. 런타임 검증의 진화 — Zod, ArkType, Valibot

문제 — 타입은 컴파일 시간만

function parseUser(json: string): User {
  return JSON.parse(json)  // 런타임에 타입 검증 없음!
}

외부 데이터(API, 폼, 파일)는 런타임 검증 필요.

Zod — 2020년 등장, 현재 표준

import { z } from 'zod'

const UserSchema = z.object({
  id: z.number(),
  email: z.string().email(),
  age: z.number().int().positive(),
})

type User = z.infer<typeof UserSchema>  // 타입 자동 추출!

const user = UserSchema.parse(externalData)  // 검증 실패 시 throw

ArkType — 타입 그대로 검증

import { type } from 'arktype'

const User = type({
  id: 'number',
  email: 'email',
  age: 'number > 0',
})

TypeScript 문법 그대로 (문자열 DSL). 엄청 빠름.

Valibot — Zod의 트리쉐이킹 개선

import { object, string, email, number, minValue, pipe } from 'valibot'

const UserSchema = object({
  email: pipe(string(), email()),
  age: pipe(number(), minValue(0)),
})

개별 함수 import → 번들 크기 70% 감소. Zod의 강력한 라이벌.

비교

측면ZodArkTypeValibot
성숙도높음중간중간
번들 크기중간최소
성능중간최고빠름
문법메서드 체인TS DSL함수형
생태계최대성장중성장중

13. tRPC — End-to-End 타입 안전성

아이디어

클라이언트 ──(타입 공유)── 서버

GraphQL 없이도 프론트엔드가 백엔드 타입을 그대로 사용.

서버 정의

// server/router.ts
import { z } from 'zod'
import { router, publicProcedure } from './trpc'

export const appRouter = router({
  getUser: publicProcedure
    .input(z.object({ id: z.number() }))
    .query(async ({ input }) => {
      return await db.users.find(input.id)
    }),
})

export type AppRouter = typeof appRouter

클라이언트 사용

// client.ts
import { createTRPCProxyClient } from '@trpc/client'
import type { AppRouter } from '../server/router'

const trpc = createTRPCProxyClient<AppRouter>({ ... })

const user = await trpc.getUser.query({ id: 1 })
// user의 타입이 자동 추론! (서버 DB 쿼리 리턴 타입)

마법

채택


14. Effect-TS — 함수형 타입의 프론티어

아이디어

Haskell의 Effect system을 TypeScript로. 부수 효과, 에러, 의존성을 타입으로 관리.

import { Effect } from 'effect'

const getUser = (id: number): Effect.Effect<User, DbError, DbService> =>
  Effect.gen(function*() {
    const db = yield* DbService
    return yield* db.findUser(id)
  })

타입 의미:

장점

단점

2024-2025 확산


15. 2025년 TypeScript 배우는 순서

초급

  1. 기본 타입 (string, number, boolean, string[], Record)
  2. Interface / Type alias
  3. Union / Intersection
  4. Generic 기초
  5. Utility types (Partial, Pick, Omit)

중급

  1. Conditional Types
  2. Mapped Types
  3. keyof, typeof, in
  4. Template Literal Types
  5. satisfies 활용
  6. Zod로 런타임 검증

고급

  1. infer 고급 활용
  2. Recursive types
  3. Variance와 함수 타입
  4. 타입 디버깅 ($ExpectType)
  5. 라이브러리 수준 타입 설계

전문

  1. Effect-TS
  2. tRPC 풀스택
  3. Type Challenges
  4. TypeScript 컴파일러 API

16. 안티패턴 TOP 10

  1. any 남용 — 타입 시스템 무효화
  2. // @ts-ignore 방치 — 버그 숨기는 습관
  3. as 강제 캐스팅satisfies가 거의 항상 낫다
  4. Enum 사용as const union이 더 안전
  5. Function 타입 — 구체적 시그너처 작성
  6. 과도한 타입 프로그래밍 — 컴파일 느려짐, 가독성 ↓
  7. 타입과 런타임 불일치 — Zod 등으로 검증
  8. readonly 누락 — 불변 의도를 타입에 표현
  9. Interface와 Type 규칙 없이 혼용 — 팀 컨벤션 필요
  10. @types/* 버전 불일치 — 런타임 버전과 맞추기

17. TypeScript 체크리스트


마치며 — 언어가 아니라 도구

Anders Hejlsberg는 한 인터뷰에서 이렇게 말했다:

"TypeScript is not trying to be the perfect type system. It's trying to be the most useful type system for the largest number of JavaScript developers."

완벽한 타입 안전성(Haskell, Rust)과 완전한 자유(동적 언어) 사이에, TypeScript는 실용주의의 정점을 점하고 있다. 그 유연성 때문에 처음엔 어중간해 보이지만, 10년 축적된 결과는 대형 팀의 생산성을 가장 극적으로 끌어올린 도구다.

2025년의 TypeScript는:

언어의 혁명은 아직 끝나지 않았다.


다음 글 예고 — Rust와 시스템 프로그래밍의 귀환

TypeScript가 "가장 사랑받는 언어 3위"라면, Rust는 8년 연속 1위다. 왜? 다음 글에서는:

시스템 프로그래밍의 르네상스를 정리하는 여정.


"TypeScript is what JavaScript could have been, if JavaScript had been designed with tooling in mind." — Daniel Rosenwasser (TypeScript Program Manager)

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다