Share
Embed
Optique: TypeScript의 타입 추론으로 CLI 유효성 검사를 대체하기
Optique는 TypeScript를 위한 타입 안전 조합형 CLI 파서 라이브러리로, “Parse, don't validate” 원칙에 따라 런타임 유효성 검사 대신 파서 구조 자체로 제약을 표현합니다. 이 발표에서는 의존적 옵션, 상호 배타적 옵션, 태그된 공용체라는 세 가지 핵심 패턴을 통해 Optique의 접근 방식을 소개합니다.
원본: https://hongminhee.codeberg.page/optique-liftio-2025/
Hong Minhee (洪 民憙)
December 06, 2025
Transcript
25 slides
-
이런 코드 본 적 있으신가요? 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("출력 형식을 하나만 선택하세요."); } // 더 많은 검증 로직… -
문제: CLI 유효성 검사의 현실 파싱과 검증의 분리 타입 정의와
검증 로직이 따로 둘 중 하나만 수정하면 불일치 발생 런타임에서만 발견되는 오류 유지보수 부담 옵션 하나 추가하면 검증 로직도 수정 어디서 무엇을 검증하는지 추적하기 어려움 -
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 -
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, … -
파서 컴비네이터란? 파서 (parser) 입력을 파싱하여 값을 반환하는 함수 컴비네이터
(combinator) 파서를 조합하여 새로운 파서를 만드는 함수 // 파서 const = ("--port", ()); // 파서들을 조합한 파서 const = ({ , : ("--host", ()), }); // 여전히 파서 const = ( , ); port option integer server object port host option string config or server client -
패턴 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 -
타입 시스템을 통한 제약의 표현 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; }'. -
패턴 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 -
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 -
패턴 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 -
그 외 기능들 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 연동 -
언제 사용하면 좋을까? 적합한 상황 복잡한 옵션 관계가 있는 CLI
도구 타입 안전성이 중요한 프로젝트 장기간 유지 보수가 예상되는 코드베이스 여러 명이 협업하는 프로젝트 과할 수 있는 경우 옵션이 한두 개뿐인 간단한 스크립트 일회성 도구 빠른 프로토타이핑이 필요한 경우 -
패키지 아키텍처 @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())) -
네 계층 아키텍처 계층 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, /* … */) // … -
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 -
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 -
우선 순위 기반 파싱 파서 우선 순위 이유 command() 15
서브커맨드 최우선 flag() 10 옵션이 인자보다 우선 option() 10 옵션이 인자보다 우선 argument() 5 위치 인자는 나중에 constant() 0 입력 소비 안 함 필요한 이유 ( ( ()), ("--file", ()), ) 우선 순위 없으면 argument() 가 --file 을 먼저 소비! or argument string option string -
구조화된 메시지 시스템 message 템플릿 리터럴 const = `옵션 ${
("--port")}는 \ ${ ("NUMBER")} 값이 필요합니다.`; MessageTerm 타입들 타입 스타일 예시 optionName 이탤릭 --verbose metavar 볼드 NUMBER value 녹색 "42" envVar 볼드+밑줄 PATH 왜 구조화된 메시지인가? 내용과 표현의 분리 의미론적 구성 요소 (옵션 이름, 값, 메타 변수 등) 터미널 환경에 맞는 자동 렌더링 에러 메시지와 도움말의 일관성 보장 msg message optionName metavar -
도움말 자동 생성 파서 → 도움말 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 서버 호스트 주소 -
광고: Hackers’ Pub에 오세요! Hackers’ Pub은 안전하고 다양성을 존중하는 소프트웨어
개발자 커뮤니티입니다. ActivityPub 기반의 분산 소셜 네트워크로, Mastodon, Threads, Misskey 등과 상호운용이 가능합니다.