Building a BMI Calculator CLI with TypeScript — Types, Functions, and Vitest This article documents a Java engineer's process of learning TypeScript by building a BMI calculator CLI tool, with the author writing all code themselves while using AI for guidance and debugging. The project implements TypeScript features including union types for BMI classification labels, separate modules for calculator logic and CLI input/output, and unit tests using Vitest with boundary value testing. The author emphasizes honest documentation of their learning journey, including what they implemented independently versus what they asked AI for assistance with. Introduction This is my first article as a Java engineer learning TypeScript from scratch. I've been writing Java professionally, but I got interested in modern tech stacks and started learning TypeScript and Python. My approach is simple: build small projects one by one, and write honestly about everything — what I struggled with, what I figured out, and what I asked AI to help with. This article is for people on a similar learning journey. It's not a showcase of perfect code — it's an honest record of the process. My Learning Style AI Transparency I use Claude Pro for design discussions and Q&A andCursor Pro for coding support as learning companions.However, I follow these rules for myself: I write all the code myself— I never ask AI to write code for me- AI helps with hints, spec clarification, and bug spotting - I make sure I understand whysomething works before moving onIn this article, I clearly separate "what I implemented myself" from "what I asked AI for." What I Built A CLI tool that calculates BMI from height and weight input. bash $ npm start Enter your height cm : 170 Enter your weight kg : 70 BMI result: 24.22 Normal BMI Classification | BMI | Label | |---|---| | < 18.5 | Underweight | | 18.5 – 24.9 | Normal | | ≥ 25.0 | Obese | 📦 Repository: https://github.com/uya0526-design/bmi-calculator https://github.com/uya0526-design/bmi-calculator Project Structure bmi-calculator/ ├── src/ │ ├── index.ts Entry point / CLI I/O │ ├── calculator.ts BMI calculation and classification logic │ ├── types.ts Type definitions │ └── tests / │ ├── calculator.test.ts Unit tests │ └── types.test.ts Type tests skipped with describe.skip ├── package.json ├── tsconfig.json └── LEARNING LOG.md Separating responsibilities by file made it much clearer where everything lived. Tech Stack - TypeScript - Node.js readline module - Vitest unit testing What I Implemented Myself types.ts — Type Definitions // Type aliases for height, weight, and BMI value type Height = number; type Weight = number; type BmiValue = number; // Union type for classification labels type BmiLabel = "Underweight" | "Normal" | "Obese"; // Object type to hold the calculation result export type BmiOutput = { bmi: BmiValue; label: BmiLabel; }; The key decision here was using a union type for BmiLabel . Any string outside of "Underweight" | "Normal" | "Obese" causes a type error at compile time. calculator.ts — Calculation Logic python import type { BmiOutput } from "./types"; function getBmiLabel bmi: number : string { if bmi < 18.5 return "Underweight"; if bmi < 25 return "Normal"; return "Obese"; } export function calculateBmi height: number, weight: number : BmiOutput { const heightInM = height / 100; const bmi = weight / heightInM 2; const label = getBmiLabel bmi ; return { bmi, label }; // shorthand property notation } I decided not to export getBmiLabel since it's only used internally — and I made that call myself. index.ts — CLI Input/Output js import as readline from "readline"; import { calculateBmi } from "./calculator"; const rl = readline.createInterface { input: process.stdin, output: process.stdout, } ; function main { rl.question "Enter your height cm : ", heightInput = { if isNaN Number heightInput { console.log "Please enter a number." ; rl.close ; return; } rl.question "Enter your weight kg : ", weightInput = { if isNaN Number weightInput { console.log "Please enter a number." ; rl.close ; return; } const result = calculateBmi Number heightInput , Number weightInput ; console.log BMI result: ${result.bmi.toFixed 2 } ${result.label} ; rl.close ; // ← position matters see "Where I Got Stuck" } ; } ; } main ; calculator.test.ts — Unit Tests with Vitest js import { describe, it, expect } from "vitest"; import { calculateBmi } from "../calculator"; describe "calculateBmi", = { it "BMI 18.49 → Underweight", = { const result = calculateBmi 170, 53.5 ; expect result.label .toBe "Underweight" ; } ; it "BMI 18.5 → Normal", = { const result = calculateBmi 170, 53.52 ; expect result.label .toBe "Normal" ; } ; it "BMI 25 or above → Obese", = { const result = calculateBmi 170, 72.25 ; expect result.label .toBe "Obese" ; } ; it "BMI calculation accuracy", = { const result = calculateBmi 170, 70 ; expect result.bmi .toBeCloseTo 24.22, 1 ; } ; } ; I deliberately chose boundary values 18.49 / 18.5 / 25 to verify the branching logic. What I Asked AI For | Topic | Details | |---|---| | Type design thinking | Asked about the difference between tuple types and object types | | tsconfig.json options | Learned what module , target , and strict each do | | Vitest setup | Confirmed the setup steps and package.json config | | Test design principles | Learned the difference between toBe and toBeOneOf , and the one-case-per-test rule | Where I Got Stuck 1. npm wouldn't run in PowerShell Cause: The default execution policy was Restricted no scripts allowed . Fix: Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process Using -Scope Process applies the change only to the current session — no permanent system changes. 2. Mismatch between package.json "type" and tsconfig.json "module" Situation: package.json had "type": "module" but tsconfig.json had "module": "commonjs" — they were out of sync. Fix: Changed package.json to "type": "commonjs" . These two settings always need to match. 3. rl.close in the wrong place Situation: I put rl.close at the end of the outer callback, but it was closing readline before the inner rl.question could finish. Insight: rl.question is asynchronous. Writing code after it doesn't mean it runs after the callback completes. Fix: Moved rl.close inside the inner callback, after all processing is done. What I Learned TypeScript | Topic | Key Takeaway | |---|---| | Union types | Union types restrict strings to allowed values, catching mistakes at compile time | | Limits of type aliases | Height = number and Weight = number are both just number at runtime — swapping arguments doesn't cause a type error Branded Types can fix this | import type | Explicitly imports types only — useful with verbatimModuleSyntax | | Shorthand properties | { bmi, label } works when variable names match property names | Testing Vitest | Topic | Key Takeaway | |---|---| | TypeScript types don't exist at runtime | Testing types with Vitest is unnecessary — the compiler already guarantees them | toBe vs toBeOneOf | toBeOneOf passes if any value matches — it can't verify correctness. Use toBe with a specific expected value | | Boundary value testing | Testing around thresholds 18.5, 25 verifies that branching logic is correct | describe.skip | Keeps the file in place while skipping tests — useful for preserving learning notes | Wrapping Up This was my first TypeScript project — a simple BMI calculator CLI. Two things stood out from this experience: Designing types first made implementation smoother Async callbacks are easy to misplace — position matters Next up: a Rock-Paper-Scissors game union types, conditionals, enums . The full learning log is in LEARNING LOG.md https://github.com/uya0526-design/bmi-calculator/blob/main/LEARNING LOG.md . This article is part of my public learning journey using AI tools Claude Pro / Cursor Pro . All code is written by me — AI is used for design discussions, bug hints, and spec clarification only.