Branded git path types
Skill kjuhwa/skills-hub/skills/typescript/branded-git-path-types
Use branded string types (`RepoPath`, `WorktreePath`, `BranchName`) with constructor helpers to prevent accidentally passing one string primitive where another is expected.From its SKILL.md
npx -y skills add kjuhwa/skills-hub --skill branded-git-path-typesAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
5.1 KB, ~1.1k tokens by cl100k_base, as published. Nobody here has run it
Branded String Types for Git Paths and Branch Names
When to use
- Your codebase has several kinds of string primitives that look the same but are semantically different:
RepoPath(canonical git repo root),WorktreePath(git worktree directory),BranchName,CodebaseId,ConversationId, etc. - You keep hitting bugs where someone passes a branch name where a path is expected (or worse, a path where a branch name is expected — the shell swallows it silently).
- You want the compiler to enforce the distinction with minimal runtime cost.
Steps
-
Declare private brand symbols and intersect with
string:declare const REPO_PATH_BRAND: unique symbol; declare const BRANCH_NAME_BRAND: unique symbol; declare const WORKTREE_PATH_BRAND: unique symbol; export type RepoPath = string & { readonly [REPO_PATH_BRAND]: true }; export type BranchName = string & { readonly [BRANCH_NAME_BRAND]: true }; export type WorktreePath = string & { readonly [WORKTREE_PATH_BRAND]: true };The brands never exist at runtime (phantom types). The
string &intersection means you can pass aRepoPathanywhere astringis expected (great for library APIs) but cannot pass a plainstringwhereRepoPathis required. -
Provide one cast helper per brand, which validates and asserts the type:
export function toRepoPath(path: string): RepoPath { if (!path) throw new Error('RepoPath cannot be empty'); return path as RepoPath; } export function toBranchName(name: string): BranchName { if (!name) throw new Error('BranchName cannot be empty'); return name as BranchName; }Runtime validation inside the helper (empty-string rejection, maybe format regex) makes the cast safer than raw
as RepoPath. The empty-string check alone has caught real bugs. -
Design function signatures to consume the branded types, forcing callers to go through the helpers:
async function isBranchMerged( repoPath: RepoPath, branchName: BranchName, mainBranch: BranchName, ): Promise<boolean> { … }Now
isBranchMerged('my-branch', repoPath, main)is a compile error — you physically cannot get the argument order wrong. -
Union helpers for functions that accept multiple kinds:
async function hasUncommittedChanges( workingPath: RepoPath | WorktreePath ): Promise<boolean> { … }|-union the brands when a function works on either. -
Pair with discriminated-union error results:
export type GitResult<T> = { ok: true; value: T } | { ok: false; error: GitError }; export type GitError = | { code: 'not_a_repo'; path: string } | { code: 'permission_denied'; path: string } | { code: 'branch_not_found'; branch: string } | { code: 'no_space'; path: string } | { code: 'unknown'; message: string };At the package boundary, return a
GitResult<T>instead of throwing raw strings. Callers pattern-match oncodeand get typedpath/branchfields.
Counter / Caveats
- Brands do not survive JSON round-trips. Deserializing from the DB gives you a
string; you must go back throughtoRepoPath(row.path)to rebrand. - Brands are not runtime-distinguishable.
typeof repoPath === 'string'is true. If you need runtime distinction (e.g. structured logging), carry the kind explicitly (e.g.{ kind: 'repo', path }). - Too many brands becomes noise. Good candidates: identifiers from external systems (ids, handles, paths), and values where accidental swap is catastrophic. Bad candidates: every internal function parameter.
- Helpers should throw on invalid input rather than returning
Option<T>— at the boundary you either have a valid value or you don't want to continue. - If you're in strict mode, the
unique symboldeclaration trick requiresskipLibCheck: falseto stay robust. Consider a named const symbol if you hit library-type conflicts.
Evidence
packages/git/src/types.ts:1-26: full declaration ofREPO_PATH_BRAND,BRANCH_NAME_BRAND,WORKTREE_PATH_BRANDplus constructorstoRepoPath,toBranchName,toWorktreePathwith empty-string guards.- Discriminated-union
GitResult<T>+GitErrorattypes.ts:29-37. - Consumers across the codebase:
packages/git/src/branch.ts(every signature usesRepoPath/BranchName),packages/isolation/src/resolver.ts,packages/core/src/services/cleanup-service.ts. - Example union at
packages/git/src/branch.ts:118:hasUncommittedChanges(workingPath: RepoPath | WorktreePath). - Commit SHA: d89bc767d291f52687beea91c9fcf155459be0d9.
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.