skills/caidanw/skills/lean-ts-patterns

lean-ts-patterns

SKILL.md

Lean TypeScript Patterns

Patterns for building lightweight, zero-dependency TypeScript/Bun tools. Distilled from studying exemplary repos: unjs/citty (CLI), unjs/consola (logging), unjs/ofetch (HTTP), unjs/defu (merging), unjs/scule (strings), unjs/pathe (paths), antfu/taze (package updates).

The 7 Principles

1. Zero Dependencies by Design

Inline tiny utils. Use node: builtins. Vendor at build time if needed.

  • CLI parsing: node:util.parseArgs (not commander/yargs)
  • Colors: 22 lines of ANSI codes (not chalk/picocolors)
  • Path utils: 7-line normalizer (not path polyfills)
  • HTTP: globalThis.fetch wrapper (not axios)
  • Object merging: 40-line recursive merge (not lodash.merge)

2. Identity Functions as Type Helpers

defineCommand, defineConfig return their argument unchanged. Their only job is type inference:

function defineCommand<const T extends ArgsDef>(def: CommandDef<T>): CommandDef<T> {
  return def;
}

The const modifier preserves literal types. Without it, { type: "boolean" } widens to { type: string }.

3. One Core Primitive, Compose Everything

Every library has one core function. Everything else is a thin wrapper:

  • scule: splitByCase() -> camelCase, kebabCase, pascalCase, snakeCase, trainCase
  • pathe: normalizeWindowsPath() -> join, resolve, normalize, relative, etc.
  • defu: _defu() -> defu, defuFn, defuArrayFn
  • consola: _logFn() -> .info(), .error(), .warn(), .debug(), etc.

4. Factory Pattern Over Classes

Closures that capture config and return composable instances:

function createFetch(globalOpts = {}) {
  const $fetch = async (url, opts) => { /* ... */ };
  $fetch.create = (defaults) => createFetch({ ...globalOpts, defaults });
  return $fetch;
}

Used by: ofetch (createFetch), consola (createConsola), defu (createDefu), citty (createMain).

5. Resolvable for Lazy/Async Values

One type that enables lazy loading everywhere:

type Resolvable<T> = T | Promise<T> | (() => T) | (() => Promise<T>);

function resolveValue<T>(input: Resolvable<T>): T | Promise<T> {
  return typeof input === "function" ? (input as any)() : input;
}

Use for subcommands, config, metadata -- anything that might be expensive to compute upfront.

6. Smart Defaults, Escape Hatches

  • ofetch: Retries default to 0 for POST/PUT/DELETE, 1 for GET
  • citty: Positional args default to required, named args to optional
  • consola: Fancy reporter in TTY, basic in CI, browser reporter in devtools
  • defu: null/undefined = "not set", let defaults fill in

7. Types Mirror Runtime

If the runtime dispatches on a discriminant, the type system should too:

// Runtime: switch on type
if (arg.type === "boolean") { /* ... */ }

// Types: conditional on same discriminant
type ParsedArg<T> =
  T["type"] extends "boolean" ? boolean :
  T["type"] extends "string" ? string :
  T["type"] extends "enum" ? T["options"][number] :
  never;

Copy-Paste Patterns

ANSI Colors (22 lines, zero deps)

const noColor = (() => {
  const env = globalThis.process?.env ?? {};
  return env.NO_COLOR === "1" || env.TERM === "dumb" || env.CI;
})();

type ColorFn = (t: string) => string;
const _c = (c: number, r = 39): ColorFn => (t) =>
  noColor ? t : `\u001b[${c}m${t}\u001b[${r}m`;

export const bold = _c(1, 22);
export const dim = _c(2, 22);
export const red = _c(31);
export const green = _c(32);
export const yellow = _c(33);
export const blue = _c(34);
export const cyan = _c(36);
export const gray = _c(90);

isPlainObject (10 lines)

function isPlainObject(value: unknown): value is Record<string, unknown> {
  if (value === null || typeof value !== "object") return false;
  const proto = Object.getPrototypeOf(value);
  if (proto !== null && proto !== Object.prototype
      && Object.getPrototypeOf(proto) !== null) return false;
  if (Symbol.iterator in value) return false;
  if (Symbol.toStringTag in value)
    return Object.prototype.toString.call(value) === "[object Module]";
  return true;
}

MaybeArray + callHooks (10 lines)

type MaybeArray<T> = T | T[];
type MaybePromise<T> = T | Promise<T>;

async function callHooks<C>(
  context: C,
  hooks: MaybeArray<(ctx: C) => MaybePromise<void>> | undefined,
): Promise<void> {
  if (!hooks) return;
  for (const hook of Array.isArray(hooks) ? hooks : [hooks]) {
    await hook(context);
  }
}

normalizeWindowsPath (7 lines)

const DRIVE_RE = /^[A-Za-z]:\//;
function normalizeWindowsPath(input = "") {
  if (!input) return input;
  return input
    .replace(/\\/g, "/")
    .replace(DRIVE_RE, (r) => r.toUpperCase());
}

Quick Reference

Need Pattern Reference
CLI argument parsing node:util.parseArgs + typed layer cli-patterns.md
Colored terminal output ANSI helper above Inline above
HTTP client with retries Fetch factory + interceptors fetch-patterns.md
Logger with levels/reporters Single-method reporter interface logging-patterns.md
Deep object merging Defaults-first recursive merge data-utils.md
String case conversion splitByCase + join variants data-utils.md
Type-safe definitions const generic + conditional types typescript-tricks.md
Lazy loading Resolvable<T> + dynamic import Inline above
Cross-platform paths normalizeWindowsPath at every entry data-utils.md

Anti-Patterns to Avoid

  • Don't pull in chalk/picocolors for colors -- 22 lines of ANSI codes suffice
  • Don't use commander/yargs -- node:util.parseArgs covers 95% of CLI needs
  • Don't use axios -- native fetch + a thin wrapper handles retries, interceptors, auto-parsing
  • Don't use lodash for one function -- inline the 10-40 lines you need
  • Don't use class hierarchies for config -- factory functions with closures are simpler
  • Don't add "flexibility" or "configurability" that wasn't requested
  • Don't make abstractions for single-use code
  • Don't export from barrel files things that should be internal -- use _ prefix convention
Weekly Installs
8
Repository
caidanw/skills
First Seen
11 days ago
Installed on
github-copilot8
codex8
amp8
cline8
kimi-cli8
gemini-cli8