I’m Kayla. I write a lot of small CLI tools in TypeScript. I pass file paths on the command line all the time. It sounds simple. It’s not always simple. But it can be.
Here’s what I liked, what made me sigh, and the exact code I use now.
I also wrote a deeper, fully narrated walkthrough that zooms in on nothing but argument handling—feel free to jump over to TypeScript file path argument — my hands-on take if you’d like an even more granular look.
Quick gut check
- What I loved: Node’s path tools are solid. Once I resolve the path, stuff just works.
- What bugged me: Windows backslashes. Paths with spaces. ESM import from a file path. Also, the “paths” setting in tsconfig fooled me at first.
- Who this helps: Anyone building a TypeScript CLI that takes a file path like –input ./data/file.json.
For an additional perspective on robust path handling in Node and TypeScript, check out this practical article on Improving Code — it echoes many of the principles I lean on here.
The simple win that saved my night
At 2 a.m., I was shipping a tiny tool for my team. It read a JSON file. I kept getting “file not found.” The fix was one line. Resolve the path against the current folder.
// src/index.ts (ESM)
import fs from "node:fs/promises";
import path from "node:path";
const args = process.argv.slice(2); // e.g. ["--input", "./data/file.json"]
function getArg(name: string): string | undefined {
const idx = args.indexOf(name);
return idx >= 0 ? args[idx + 1] : undefined;
}
const rawInput = getArg("--input");
if (!rawInput) {
console.error("Please pass --input <path>");
process.exit(1);
}
// Always resolve against where the user runs the command
const inputPath = path.resolve(process.cwd(), rawInput);
const text = await fs.readFile(inputPath, "utf8");
console.log("File length:", text.length);
Run it like this:
- macOS/Linux:
- node dist/index.js –input ./data/user.json
- Windows (PowerShell):
- node dist/index.js –input ".datauser.json"
You know what? That one path.resolve(process.cwd(), rawInput) line fixed three weird cases for me: dots in paths, spaces in folder names, and different shells. If you want to see how Node itself explains the nuances of absolute versus relative paths, the concise official guide on working with file paths is worth a skim.
Windows paths, quotes, and tiny traps
On my Mac, I forget about quotes. On Windows, I can’t. If a path has spaces, quotes matter:
- Works:
- node dist/index.js –input "C:UsersKaylaMy Docsusers.json"
- Breaks:
- node dist/index.js –input C:UsersKaylaMy Docsusers.json
Also, ~ does not expand to your home folder in Node args. I wish it did. I wrote a tiny helper.
import os from "node:os";
import path from "node:path";
function expandTilde(p: string) {
if (!p.startsWith("~")) return p;
return path.join(os.homedir(), p.slice(1));
}
Then I do:
const inputPath = path.resolve(process.cwd(), expandTilde(rawInput));
ESM import from a file path (the not-so-fun bit)
I wanted to let folks pass a path to a config file that exports JS. With ESM, you can’t import a plain file path string. You need a file URL. This tripped me up for a whole morning.
// Load a JS/TS config via ESM
import { pathToFileURL } from "node:url";
import path from "node:path";
async function loadConfig(p: string) {
const full = path.resolve(process.cwd(), p);
const url = pathToFileURL(full).href;
try {
const mod = await import(url);
return mod.default ?? mod.config ?? mod;
} catch (err) {
console.error("Failed to import config at:", full);
throw err;
}
}
It looks fussy. But it’s steady. It works on macOS and Windows for me with tsx and Node 20.
Globs made easy (batch files, but simple)
Sometimes I want to pass a bunch of files like src/**/*.ts. I use fast-glob. It respects .gitignore too, which is nice.
import fg from "fast-glob";
import path from "node:path";
async function expandInputs(patterns: string[]) {
const resolved = patterns.map(p => path.resolve(process.cwd(), p));
return await fg(resolved, { onlyFiles: true });
}
// Example: node dist/index.js --input "src/**/*.ts"
Little note: I resolve first, then pass to fast-glob. That kept my results stable across shells.
“paths” in tsconfig is not a user path feature
This one stung. I set this in tsconfig:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}
Nice for imports like import foo from "@/utils/foo". But that does nothing for CLI args. If a user passes "@/data/sample.json", your tool won’t find it. It’s a compiler thing, not a runtime thing. So I keep user file paths real and plain.
A tiny but complete CLI I ship at work
This is my current pattern. It’s boring. It’s also calm.
// src/cli.ts (ESM)
// Run with: tsx src/cli.ts --input ./data/input.json
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { pathToFileURL } from "node:url";
// Parse flags without extra libs
const args = process.argv.slice(2);
function getFlag(name: string) {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
}
function hasFlag(name: string) {
return args.includes(name);
}
function expandTilde(p: string) {
if (!p || !p.startsWith("~")) return p;
return path.join(os.homedir(), p.slice(1));
}
function normalizeUserPath(p: string) {
return path.resolve(process.cwd(), expandTilde(p));
}
const input = getFlag("--input");
const config = getFlag("--config");
const useStdin = hasFlag("--stdin");
let text: string;
if (useStdin) {
// Fallback: read from stdin
text = await new Promise<string>((res, rej) => {
let buf = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", chunk => (buf += chunk));
process.stdin.on("end", () => res(buf));
process.stdin.on("error", rej);
});
} else if (input) {
const filePath = normalizeUserPath(input);
text = await fs.readFile(filePath, "utf8");
} else {
console.error("Pass --input <path> or --stdin");
process.exit(1);
}
let cfg: any = {};
if (config) {
const url = pathToFileURL(normalizeUserPath(config)).href;
cfg = (await import(url)).default ?? {};
}
console.log("Chars:", text.length, "Config keys:", Object.keys(cfg).length);
Why I like this:
- It handles stdin. Good for piping.
- It expands ~. People use it without thinking.
- It resolves paths the same way every time.
Stuff that made me grumpy (but I solved it)
- Backslashes: I now print the resolved path on errors. Users see what the tool sees.
- Spaces: I tell Windows folks to use quotes. I also show an example in –help.
- Symlinks:
path.resolveis fine, but if you need the real file, usefs.realpath. - ts-node vs tsx: I had fewer ESM headaches with tsx. So I stick with tsx for dev.
- CI weirdness: Always use
process.cwd()and not__dirnamefor user args. That kept my builds stable on GitHub Actions.
Need to clean up temporary build artefacts as part of your workflow? I keep a concise, battle-tested snippet in my honest take on deleting files with Node.js that you can drop straight into any CLI.
On a lighter note, I’m fascinated by how developers and everyday users alike repurpose familiar platforms for entirely new objectives—watching that creativity can spark fresh ideas for tool-building and edge-case testing. A quirky example is the emerging trend of treating LinkedIn as a matchmaking hub; the thoughtful analysis at LinkedIn Dating — Why and How It’s Becoming a Thing digs
