lean-ts-patterns

Patterns for building lightweight, zero-dependency TypeScript tools and libraries. Use when building new CLI tools, libraries, or utilities from scratch. Use when refactoring existing TypeScript projects to remove unnecessary dependencies. Use when writing fetch wrappers, CLI parsers, loggers, config merging, string utilities, or any infrastructure code that should be lean and self-contained. Triggers on: "build a CLI", "write a logger", "fetch wrapper", "remove dependencies", "lightweight", "zero-dep", "inline utility", "refactor to be simpler".

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "lean-ts-patterns" with this command: npx skills add caidanw/skills/caidanw-skills-lean-ts-patterns

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<T> 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

NeedPatternReference
CLI argument parsingnode:util.parseArgs + typed layercli-patterns.md
Colored terminal outputANSI helper aboveInline above
HTTP client with retriesFetch factory + interceptorsfetch-patterns.md
Logger with levels/reportersSingle-method reporter interfacelogging-patterns.md
Deep object mergingDefaults-first recursive mergedata-utils.md
String case conversionsplitByCase + join variantsdata-utils.md
Type-safe definitionsconst generic + conditional typestypescript-tricks.md
Lazy loadingResolvable<T> + dynamic importInline above
Cross-platform pathsnormalizeWindowsPath at every entrydata-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

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Coding

typescript

No summary provided by upstream source.

Repository SourceNeeds Review
General

modern-css

No summary provided by upstream source.

Repository SourceNeeds Review
General

karpathy-guidelines

No summary provided by upstream source.

Repository SourceNeeds Review