$30 off During Our Annual Pro Sale. View Details »

Optique: TypeScript의 타입 추론으로 CLI 유효성 검사를 대체하기

Optique: TypeScript의 타입 추론으로 CLI 유효성 검사를 대체하기

Optique는 TypeScript를 위한 타입 안전 조합형 CLI 파서 라이브러리로, “Parse, don't validate” 원칙에 따라 런타임 유효성 검사 대신 파서 구조 자체로 제약을 표현합니다. 이 발표에서는 의존적 옵션, 상호 배타적 옵션, 태그된 공용체라는 세 가지 핵심 패턴을 통해 Optique의 접근 방식을 소개합니다.

원본: https://hongminhee.codeberg.page/optique-liftio-2025/

Avatar for Hong Minhee (洪 民憙)

Hong Minhee (洪 民憙)

December 06, 2025
Tweet

More Decks by Hong Minhee (洪 民憙)

Other Decks in Programming

Transcript

  1. 이런 코드 본 적 있으신가요? const opts = parseArgs(process.argv); if

    (!opts.server && opts.port) { throw new Error("--port 옵션은 --server 플래그가 필요합니다."); } if (opts.server && !opts.port) { opts.port = 3000; // 기본 포트 } if ((opts.json ? 1 : 0) + (opts.yaml ? 1 : 0) > 1) { throw new Error("출력 형식을 하나만 선택하세요."); } // 더 많은 검증 로직…
  2. 문제: CLI 유효성 검사의 현실 파싱과 검증의 분리 타입 정의와

    검증 로직이 따로 둘 중 하나만 수정하면 불일치 발생 런타임에서만 발견되는 오류 유지보수 부담 옵션 하나 추가하면 검증 로직도 수정 어디서 무엇을 검증하는지 추적하기 어려움
  3. JSON 파싱으로 비유하면 안 좋은 방식 좋은 방식 const :

    unknown = . ( ); if (typeof !== "object" || === null) { throw new (); } else if (!("name" in ) || typeof . !== "string") { throw new (); } else if (!("age" in ) || typeof . !== "number") { throw new (); } else if ( . < 0) { throw new (); } // … data JSON parse response data data Error data data name Error data data age Error data age Error const = . ({ : . (), : . (). () }); const = . ( . ( )); userSchema z object name z string age z number nonnegative data userSchema parse JSON parse response
  4. Optique 파서 컴비네이터 기반 CLI 파서 TypeScript의 타입 추론 활용

    Haskell의 optparse-applicative에서 영감 “Parse, don’t validate” 원칙을 CLI에 적용 문서: optique.dev GitHub: dahlia/optique npm: @optique/core, @optique/run, … JSR: @optique/core, @optique/run, …
  5. 파서 컴비네이터란? 파서 (parser) 입력을 파싱하여 값을 반환하는 함수 컴비네이터

    (combinator) 파서를 조합하여 새로운 파서를 만드는 함수 // 파서 const = ("--port", ()); // 파서들을 조합한 파서 const = ({ , : ("--host", ()), }); // 여전히 파서 const = ( , ); port option integer server object port host option string config or server client
  6. 패턴 1: 의존적 옵션 기존 방식 검증 로직이 분산됨 타입이

    실제 제약을 반영하지 못함 런타임에만 오류 발견 Optique 방식 --port 옵션은 --server 와 함께만 사용 가능한 경우 const opts = parseArgs(process.argv); if (!opts.server && opts.port) { throw new Error("--port는 --server가 필요합니다"); } if (opts.server && !opts.port) { opts.port = 3000; } const = ( ({ : ("--server"), : ("--port", ()), : ("--workers", ()) }), { : false } as ); type = <typeof >; config withDefault object server flag port option integer workers option integer server const Config InferValue config
  7. 타입 시스템을 통한 제약의 표현 const : = ( );

    if ( . ) { // server가 true일 때만 port 접근 가능 . (`Server on port ${ . }`); } else { // server가 false일 때 port 접근 시도 . ( .port); // ❌ 컴파일 오류! } result Config run config result server console log result port console log result Property 'port' does not exist on type '{ readonly server: false; }'.
  8. 패턴 2: 상호 배타적 옵션 기존 방식 세 개의 boolean

    을 하나의 선택으로 변환 실제로는 정확히 하나만 true 여야 하는데 타입이 이를 표현 못함 Optique 방식 --json / --yaml / --xml 옵션 중 하나만 선택 가능한 경우 if ((opts.json ? 1 : 0) + (opts.yaml ? 1 : 0) + (opts.xml ? 1 : 0) !== 1) { throw new Error("출력 형식을 하나만 선택하세요."); } let format: "json" | "yaml" | "xml"; if (opts.json) format = "json"; else if (opts.yaml) format = "yaml"; else if (opts.xml) format = "xml"; else throw new Error("실제로는 여기로 오면 안 됨."); const = ( ( ("--json"), () => "json" as ), ( ("--yaml"), () => "yaml" as ), ( ("--xml"), () => "xml" as ), ); type = <typeof >; format or map flag const map flag const map flag const Format InferValue format
  9. or() 컴비네이터 여러 파서 중 최초로 성공한 파서의 값을 반환

    const = ( ( ("--json"), () => "json" as ), ( ("--yaml"), () => "yaml" as ), ( ("--xml"), () => "xml" as ), ); const : "json" | "yaml" | "xml" = ( ); // switch문으로 처리 switch ( ) { case "json": return . ( ); case "yaml": return ( ); case "xml": return ( ); // 모든 경우를 처리하지 않으면 타입 오류 } format or map flag const map flag const map flag const result run format result JSON stringify data toYaml data toXml data
  10. 패턴 3: 태그된 공용체 (tagged union) 환경에 따라 완전히 다른

    옵션 집합을 사용하는 경우 const = ( ({ : ("prod"), : ("--auth", ()), // prod 필수 : ("--ssl"), }), ({ : ("dev"), : ("--debug"), }) ); type = <typeof >; envConfig or object env constant auth option string ssl option object env constant debug option EnvConfig InferValue envConfig ( .auth); const : = ( ); config EnvConfig run envConfig if ( . === "prod") { config env ( . ); authenticate config auth if ( .debug) { config Property 'debug' does not exist on type '{ readonly env: "prod"; readonly auth: string; readonly ssl: boolean; }'. // ... } } else { if ( . ) { config debug (); enableDebug } authenticate config
  11. 그 외 기능들 Unix 스타일 짧은 옵션 ( -a ,

    -b , -ab ) GNU 스타일 긴 옵션 ( --foo , --bar=baz , --qux quux ) DOS 스타일 옵션 ( /foo , /bar:baz ) Java 스타일 옵션 ( -foo , -bar=baz ) 옵션 그룹화 중첩 서브커맨드 도움말 ( --help ) 자동 생성 서식화된 오류 메시지 셸 완성 지원 (Bash, zsh, fish, PowerShell, Nushell) 오타 수정 제안 (Did you mean?) Zod 및 Valibot 연동
  12. 언제 사용하면 좋을까? 적합한 상황 복잡한 옵션 관계가 있는 CLI

    도구 타입 안전성이 중요한 프로젝트 장기간 유지 보수가 예상되는 코드베이스 여러 명이 협업하는 프로젝트 과할 수 있는 경우 옵션이 한두 개뿐인 간단한 스크립트 일회성 도구 빠른 프로토타이핑이 필요한 경우
  13. 패키지 아키텍처 @optique/core 런타임 독립적인 핵심 파싱 로직 브라우저, 웹

    워커에서도 사용 가능 파서 컴비네이터, 값 파서, 메시지 시스템 @optique/run 프로세스 통합 ( process.argv , process.exit() ) 터미널 색상/너비 자동 감지 path() 값 파서 @optique/temporal TC39 Temporal API 값 파서 instant() , duration() , plainDate() 등 @optique/zod · @optique/valibot Zod/Valibot 스키마를 값 파서로 변환 기존 검증 로직 재사용 zod(z.string().email()) , valibot(v.pipe(v.string(), v.email()))
  14. 네 계층 아키텍처 계층 1: 값 파서 (value parsers) 계층

    2: 기본 파서 (primitives) 계층 3: 수정자 (modifiers) 계층 4: 구성자 (constructs) string() integer() url() choice() // … flag() option() argument() command() constant() optional(parser) withDefault(parser, defaultValue) map(parser, transform) // … object({ /* … */ }) or(parser1, parser2, /* … */) merge(parser1, parser2, /* … */) // …
  15. Parser 인터페이스 interface < , > { ( : <

    >): < > ( : ): < > // TypeScript 타입 추론을 위한 팬텀 타입들 readonly : readonly [] readonly : readonly [] } 두 개의 타입 파라미터 TValue : 최종 결과 타입 (사용자가 받는 값) TState : 파싱 중 상태 타입 (내부 구현) 중간 상태를 추적하여 "시도했지만 실패"와 "시도 안 함" 구분 Parser TValue TState parse context ParserContext TState ParserResult TState complete state TState ValueParserResult TValue $valueType TValue $stateType TState
  16. InferValue<T> 유틸리티 타입 구현 팬텀 타입(phantom type)으로 타입 추론 사용

    예시 왜 배열을 사용할까? TypeScript의 타입 추론 한계 우회 런타임 값은 빈 배열 [] 이지만, 타입만 중요 $valueType[number] 로 타입 추출 Haskell의 Data.Proxy.Proxy 타입과 유사한 역할 type < extends <unknown, unknown>> = ["$valueType"][number]; InferValue T Parser T const = ("--port", ()); type = <typeof >; portParser option integer PortType InferValue portParser
  17. 우선 순위 기반 파싱 파서 우선 순위 이유 command() 15

    서브커맨드 최우선 flag() 10 옵션이 인자보다 우선 option() 10 옵션이 인자보다 우선 argument() 5 위치 인자는 나중에 constant() 0 입력 소비 안 함 필요한 이유 ( ( ()), ("--file", ()), ) 우선 순위 없으면 argument() 가 --file 을 먼저 소비! or argument string option string
  18. 구조화된 메시지 시스템 message 템플릿 리터럴 const = `옵션 ${

    ("--port")}는 \ ${ ("NUMBER")} 값이 필요합니다.`; MessageTerm 타입들 타입 스타일 예시 optionName 이탤릭 --verbose metavar 볼드 NUMBER value 녹색 "42" envVar 볼드+밑줄 PATH 왜 구조화된 메시지인가? 내용과 표현의 분리 의미론적 구성 요소 (옵션 이름, 값, 메타 변수 등) 터미널 환경에 맞는 자동 렌더링 에러 메시지와 도움말의 일관성 보장 msg message optionName metavar
  19. 도움말 자동 생성 파서 → 도움말 const = ({ :

    ( ("--port", "-p", ({ : "PORT" }), { : `서버 포트 번호`, }), 8080, ), : ("--host", ({ : "HOST" }), { : `서버 호스트 주소`, }), }); const = ( ); const = ("myapp", !, { : true, : true, }); . ( ); 출력 예시 파서 구조에서 자동 추출 기본값 표시 그룹화 지원 중간 표현인 DocPage 제공 ( man 페이지 등 다른 형식으로 변환 가능) ← 실제 사용시에는 도움말 출력을 직접 안 해도 됨! parser object port withDefault option integer metavar description message host option string metavar description message doc getDocPage parser rendered formatDocPage doc colors showDefault console log rendered Usage: myapp [--port/-p PORT] --host HOST --port, -p PORT 서버 포트 번호 [8080] --host HOST 서버 호스트 주소
  20. 광고: Hackers’ Pub에 오세요! Hackers’ Pub은 안전하고 다양성을 존중하는 소프트웨어

    개발자 커뮤니티입니다. ActivityPub 기반의 분산 소셜 네트워크로, Mastodon, Threads, Misskey 등과 상호운용이 가능합니다.