dry4ts
v0.2.0
Published
Structural duplicate-code finder for TypeScript and TSX.
Downloads
510
Readme
dry4ts
dry4ts is a structural duplicate-code finder for TypeScript and TSX. It looks
past names and literals to the shape of your code, so declarations that were
copied once and have since drifted apart still turn up as a matching pair,
reported by file and line range. It ships as a single Rust binary through npm,
so npx dry4ts . runs it with no toolchain to set up.
How it works
dry4ts picks out the declarations worth comparing -- functions, methods, arrow functions, classes, interfaces, type aliases, enums, constructors, and accessors -- and rewrites each into a normalized form that keeps its structural kinds and operator/modifier markers while dropping names and values. It then fingerprints that form at every level -- the whole declaration and each node within it -- and scores each pair by how much their fingerprint sets overlap:
score = fingerprints present in both / fingerprints present in either1.0 means the two normalized shapes are indistinguishable. The score drops as
each side gains structure the other lacks. Any pair scoring at or above
--threshold (default 0.82) is reported.
Structure that survives normalization includes:
- the shape of functions, methods, and arrow functions
- parameter lists and type annotations
- statement blocks and their order
- control flow:
if,for,for-of,while,switch - assignments, returns, calls, property access, and indexing
- array, object, and JSX literals
- operators (
+,===,&&,||, and the rest) - modifiers (
export,async,public,private,static,readonly)
For instance, these two validators share no names, fields, messages, or bounds,
yet they normalize to the same shape -- so dry4ts scores them 1.0:
function validateProfile(input: ProfileDraft): string[] {
const problems: string[] = [];
if (input.displayName.trim() === "") {
problems.push("display name is required");
}
if (input.age < 13) {
problems.push("age is below the minimum");
}
return problems;
}
function checkVenue(form: VenueForm): string[] {
const issues: string[] = [];
if (form.title.trim() === "") {
issues.push("title is required");
}
if (form.capacity < 1) {
issues.push("capacity is below the minimum");
}
return issues;
}Install
Run it once, without installing anything:
npx dry4ts .Or keep it as a dev dependency:
npm install --save-dev dry4tsThe prebuilt binary for your platform comes down automatically as an optional dependency -- there is nothing to compile and no separate download.
Usage
dry4ts [options] [file-or-directory ...]Options:
--threshold N report pairs scoring at least N (0.0-1.0); default 0.82
--min-lines N skip candidates shorter than N source lines; default 4
--min-nodes N skip candidates with fewer than N normalized nodes; default 20
--format F "text" or "json"; default text
--json shorthand for --format json
--text shorthand for --format text
--ignore GLOB exclude paths matching GLOB (repeatable; gitignore-style)
--config PATH use PATH as the config file (skips discovery)
--no-config ignore any .dry4tsrc.json discovery
--fail-on-found exit 3 if any duplicate pairs are reported
--help, -h print this helpExamples:
dry4ts .
dry4ts src/foo.ts src/bar.ts
dry4ts --json --threshold 0.9 ./src
dry4ts --ignore '**/*.test.{ts,tsx}' --fail-on-found ./srcEvery path on the command line feeds into one shared comparison set. A directory
argument is walked recursively for .ts, .tsx, .mts, and .cts files;
.git, node_modules, and dist are always skipped, plus anything your config
or --ignore excludes. CLI flags override the config file, which overrides the
built-in defaults.
Configuration
Commit a .dry4tsrc.json (discovered by walking up from the working directory)
so your package.json script and CI stop being flag soup:
{
"threshold": 0.85,
"minLines": 6,
"minNodes": 24,
"ignorePatterns": ["**/*.test.{ts,tsx}", "**/types/database.ts"],
"failOnFound": true
}Keys mirror the flags (threshold, minLines, minNodes, format,
ignorePatterns, failOnFound); an unknown key is an error. ignorePatterns
are gitignore-style and exclude-only: a matched file is dropped on its own
(**/*.test.ts keeps its non-test siblings) and a matched directory is pruned.
dry4ts does not read your .gitignore -- excludes are explicit. Use
--config PATH to load an exact file or --no-config to disable discovery.
The default text format is meant to be skimmed:
DUPLICATE score=0.91
src/forms/profile.ts:8-19
src/forms/venue.ts:24-35The JSON format is meant for other tools:
{
"candidates": [
{
"score": 0.91,
"left": {"file": "src/forms/profile.ts", "start_line": 8, "end_line": 19},
"right": {"file": "src/forms/venue.ts", "start_line": 24, "end_line": 35},
"left_nodes": 54,
"right_nodes": 54
}
]
}With no matches, text mode prints No duplicate candidates found. and JSON mode
prints { "candidates": null }.
Exit codes
0-- ran successfully (with or without matches); also--help.1-- a file could not be read, or a directory walk failed.2-- a bad argument or a configuration error: an unknown flag, a malformed or unreadable config, an unknownformat, or an invalid ignore glob (the message and usage go to stderr).3-- ran successfully and reported a duplicate, only under--fail-on-found(orfailOnFound). Off by default.
A file that cannot be parsed is noted on stderr, skipped, and the scan continues
-- still exiting 0 (or 3 under --fail-on-found). Exit 1 is reserved for
genuine I/O failures.
Under the hood
Parsing is handled by oxc, a fast Rust TypeScript and JSX parser.
License
MIT. Copyright (c) 2026 sustinbebustin.
