안녕하세요! TypeScript로 CLI 도구를 자주 만들다 보니 기존 라이브러리들의 한계가 아쉬워서 새로운 CLI 파서를 만들게 되었습니다. 혹시 관심 있으신 분들께 소개해보고 싶어 글을 올립니다.

CLI 애플리케이션을 개발하면서 늘 불편했던 점이 하나 있었습니다. 기존 CLI 파서 라이브러리들은 대부분 설정 객체나 명령형 API로 CLI 구조를 정의하는데, 이렇게 하면 타입 안전성은 물론이고 복잡한 CLI 구조를 표현하기도 어렵습니다.

특히 상호 배타적인(mutually exclusive) 옵션 그룹을 표현하려면 별도의 검증 로직을 여기저기 흩어뜨려야 했습니다. “이 옵션과 저 옵션은 동시에 쓸 수 없다”, “이 모드에서는 이런 옵션만 허용된다” 같은 제약을 코드로 깔끔하게 표현하기 어려웠습니다. 그리고 TypeScript를 써도 파싱 결과의 타입을 수동으로 정의해야 하는 경우가 많았습니다.

함수형 파서 컴비네이터(parser combinator)라는 접근

그래서 Haskell의 optparse-applicative에서 영감을 받아 함수형 파서 컴비네이터 방식으로 TypeScript CLI 파서를 만들어봤습니다.

기존 방식:

// 기존 라이브러리들의 전형적인 방식  
const program = new Command()  
  .option('-p, --port <number>', 'port number')  
  .option('-h, --host <string>', 'hostname')  
  .action((options) => {  
    // options의 타입은 any 또는 수동으로 정의해야 함  
  });  

Optique 방식:

// 작은 파서들을 조합해서 큰 구조를 만듦  
const serverConfig = object({  
  port: option("-p", "--port", integer({ min: 1, max: 65535 })),  
  host: option("-h", "--host", string()),  
  verbose: option("-v", "--verbose")  
});  
  
// TypeScript가 자동으로 타입을 추론!  
// { port: number, host: string, verbose: boolean }  
const config = run(serverConfig);  

차별점 1: 상호 배타적 옵션을 구조로 표현

가장 큰 차별점은 상호 배타적인 옵션 그룹을 자연스럽게 표현할 수 있다는 점입니다. 기존 라이브러리들은 이런 제약을 별도 검증 로직으로 처리해야 했는데, Optique는 or() 컴비네이터로 구조 자체에 제약을 녹여낼 수 있습니다.

// 서버 모드 vs 클라이언트 모드 - 완전히 다른 옵션 세트  
const parser = or(  
  object({  
    mode: constant("server"),  
    port: option("-p", "--port", integer()),  
    workers: option("-w", "--workers", integer()),  
    ssl: option("--ssl")  
  }),  
  object({  
    mode: constant("client"),   
    connect: option("-c", "--connect", string()),  
    timeout: option("-t", "--timeout", integer()),  
    retries: option("--retries", integer())  
  })  
);  
  
// TypeScript가 자동으로 discriminated union 생성  
// { mode: "server", port: number, workers: number, ssl: boolean } |   
// { mode: "client", connect: string, timeout: number, retries: number }  

기존 라이브러리라면 이런 검증을 수동으로 해야 했을 것입니다:

// 기존 방식의 번거로움  
if (options.mode === "server" && options.connect) {  
  throw new Error("--connect는 서버 모드에서 사용할 수 없습니다");  
}  
if (options.mode === "client" && options.workers) {  
  throw new Error("--workers는 클라이언트 모드에서 사용할 수 없습니다");  
}  

차별점 2: 완전 자동 타입 추론

const gitLike = or(  
  command("add", object({  
    type: constant("add"),  
    files: multiple(argument(string())),  
    all: option("-A", "--all")  
  })),  
  command("commit", object({  
    type: constant("commit"),  
    message: option("-m", "--message", string()),  
    amend: option("--amend")  
  }))  
);  
  
// 결과는 discriminated union으로 자동 추론됨  
const result = run(gitLike);  
if (result.type === "add") {  
  // TypeScript가 알아서 타입 좁히기를 해줌  
  console.log(`Adding ${result.files.join(", ")}`);  
}  

차별점 3: 모듈화와 재사용성

merge() 컴비네이터로 옵션 그룹을 재사용할 수 있어서, 여러 커맨드에서 공통 옵션을 쉽게 공유할 수 있습니다.

// 재사용 가능한 옵션 그룹 정의  
const networkOptions = object({  
  host: option("--host", string()),  
  port: option("--port", integer())  
});  
  
const authOptions = object({  
  username: option("-u", "--user", string()),  
  password: optional(option("-p", "--password", string()))  
});  
  
// 필요에 따라 조합  
const devMode = merge(networkOptions, object({ debug: option("--debug") }));  
const prodMode = merge(networkOptions, authOptions, loggingOptions);  

차별점 4: 풍부한 내장 검증

값 파서들이 단순 타입 변환을 넘어서 의미 있는 검증을 제공합니다.

const parser = object({  
  // 파일 시스템에서 실제 존재 여부 검사  
  inputFile: option("--input", path({ mustExist: true })),  
  
  // 포트 번호 범위 검증  
  port: option("-p", "--port", integer({ min: 1, max: 65535 })),  
  
  // URL 프로토콜 제한  
  api: option("--api", url({ allowedProtocols: ["https:"] })),  
  
  // 선택지 제한  
  logLevel: option("--log", choice(["debug", "info", "warn", "error"]))  
});  

런타임 지원

  • @optique/core: 모든 JavaScript 런타임 지원 (브라우저, 에지 함수 등)
  • @optique/run: Node.js, Bun, Deno용 배터리 내장 버전

설치:

deno add --jsr @optique/core @optique/run  
npm  add       @optique/core @optique/run  
pnpm add       @optique/core @optique/run  
yarn add       @optique/core @optique/run  
bun  add       @optique/core @optique/run  

마치며

기존 CLI 라이브러리들이 “설정을 통해 파서를 만드는” 방식이라면, Optique는 “작은 파서들을 조합해서 큰 파서를 만드는” 함수형 접근입니다.

특히 상호 배타적인 옵션 그룹을 표현할 때 이 차이가 확실히 드러납니다. 복잡한 CLI 제약사항을 별도 검증 로직 없이 파서 구조 자체로 표현할 수 있어서, 타입 안전성과 코드 간결성을 동시에 얻을 수 있습니다.

물론 아직 초기 개발 단계라 API가 변경될 수 있지만, 함수형 파서 컴비네이터의 우아함을 TypeScript CLI 개발에 가져오고 싶은 분들이라면 한 번 써보시면 좋을 것 같습니다.