This is part of my series "My Open-Source Projects", where I walk through the projects I started or helped to grow – with the back story, what they do well, where they struggle, and what's next. Previous parts: AngleSharp, MAGES, Piral, Electron.NET, Netpack and Picard.js.
We are getting close to the end of this series: only two projects left. Today's is one of the quieter ones – it has no logo that anyone remembers, no conference talk, and no hype. What it has is a job it does reliably: dets, a TypeScript declaration file bundler.
It started as a helper for Piral, and it grew up a bit since then.
What is dets?
dets generates a single .d.ts file from your TypeScript code. You tell it what the entry points are, and it produces one self-contained declaration file with only the API you care about – and no dependency on other declaration packages.
That's useful in two situations:
- You want to selectively expose an API (and not everything that happens to be
exported somewhere). - You want a self-contained declaration file that you can ship, copy, or publish as-is.
The package description says exactly this, in five words: "A TypeScript declaration file bundler". It's MIT licensed, lives at github.com/FlorianRappl/dets, and – to my own surprise – is downloaded several thousand times per week.
The back story: a shell for plugins
To explain why dets exists I have to explain how Piral thinks about the world.
In Piral, the app shell is a plugin-enhanced app: it loads micro frontends (pilets) at runtime and gives them an API to integrate with it – register a page, add an extension, access shared data, and so on. For micro frontend teams to be productive they need that shell on their machine: to debug locally, and to build and check their code against what the shell really offers.
And since the shell comes with an API, the natural thing (at least in the web world) is to ship TypeScript declarations for it. Then the IDE can tell you what is available, and your build tool can complain if your assumptions are wrong. (Compile errors are better than production errors. Always.)
"But the shell is written in TypeScript – don't we get that for free?"
That was my first thought, too. It is also wrong, for a few reasons:
- The app shell is an application, not a library. Most of what's in there is not meant for micro frontends.
- The API for the micro frontends is only a subset of the app shell.
- The shell brings nothing of that API to the runtime. The types are compile-time only. There is no object you can point at and say "this is the API".
So what you'd need is a declaration file that describes just the part that is relevant. And that's something TypeScript's own tsc --declaration does not give you: it emits one declaration file per source file, mirrors your internal structure, and happily references everything.
The failed attempts
First I tried the obvious: take the plugin API definition of the app shell and feed it to the various type bundling tools out there. It did not go well. Whatever I tried either failed outright or produced vast amounts of ill-formatted garbage.
At some point you have to accept the pattern: type bundling – or type extraction in general – is not a solved problem.
One weekend, one experiment
That's when I spent a weekend on an experiment: Can I use the TypeScript compiler API to actually infer the types and re-build a .d.ts with only the API I care about?
It turns out: yes. But not easily.
Along the way I learned an awful lot about how TypeScript infers and enriches type information, and how its internal model is built up – symbols, declarations, types, flags, merging, aliases… you name it. Things that you never need to think about when writing TypeScript become very present when you try to print it.
The main catch, however, was the API itself. It was meant to be internal. At some point the TypeScript team wanted (and even tried out) a plugin story with real API users. But it seems they found it easier to leave it undocumented and keep it their safe space. Whoever uses the API should be prepared for surprises in future versions.
So dets became a tool that stands on an unstable foundation by design. That sounds worse than it is – it just means that keeping up with TypeScript versions is a feature of this project, not an accident (more on that below – including a look at what's inside).
Getting started
Install it as a dev dependency:
npm i dets --save-dev
(npx dets or a global install work, too.) Then point it at your entry module:
dets src/index.ts --name foo --files "src/**/*.ts" --out dist/index.d.ts
The options you will meet most often:
| Option | What it does |
|---|---|
entry (positional) |
The entry-level modules to consume. This is what you should reach for in most cases. |
--name |
The module name. If omitted, it's taken from the nearest package.json. |
--files |
The files TypeScript gets to see. |
--imports |
Packages whose imports should stay external (not bundled in). |
--ignore |
Respect @ignore comments and drop those members (default: true). |
--module-declaration |
Wrap the output in a declare module "name" { … } block (default: true). |
--out |
The output path (default: ./dist/index.d.ts). |
There are also lower-level options (--types, --apis) for selecting types by file and interfaces by InterfaceName:FilePath – handy when the API is defined as an interface, but not exported from a single entry point (hello, app shells).
From code
If you want to integrate dets into your own tooling, there is a programmatic API, too:
import { generateDeclaration } from 'dets';
import { writeFileSync } from 'fs';
const content = await generateDeclaration({
name: 'my-app-shell',
root: process.cwd(),
files: ['src/**/*.ts'],
types: ['src/api.ts'],
});
writeFileSync('dist/index.d.ts', content);
generateDeclaration returns the content; writing it somewhere is up to you. (If you need finer control, setupVisitorContext, fillVisitorContextFromApi and fillVisitorContextFromTypes give you the building blocks – e.g. to say "use this exported object as the API".)
Shaping the output
Bundling everything that is reachable is the easy part. The interesting part is controlling what ends up in the file.
Special comments
dets understands a few comment tags:
export interface Foo {
bar: string;
/**
* Internal – should not be visible for consumers.
* @ignore
*/
secret: number;
}
-
@ignoredrops the member from the output (can be disabled via--no-ignore). -
@dets_preservekeeps an@ignored member and removes only the tag itself – useful if something is hidden in one context but needed in another. -
@dets_removepropand@dets_removeclauseare for merged interfaces: they remove named properties orextendsclauses from the combination. (Declaration merging is a powerful TypeScript feature, and a splendid source of surprises.)
Plugins
For everything else there are plugins. They hook into the phases shown above (before-init, before-process, after-process, before-stringify) and can modify the model before it is printed. There are two styles: a classic one – an object with type, name and run(context) – and a modern one, where a single plugin implements multiple lifecycle hooks:
const plugin = {
name: 'my-plugin',
'before-init'(context) {
// prepare something
},
'after-process'(context) {
// adjust the collected types
},
};
There is also a built-in createExcludePlugin(['some-module']) to drop whole modules from the output (declaration merges on other modules still apply).
Under the hood: the TypeScript compiler API
Time to open the hood. If you have never touched the compiler API, here's the mental model that made everything click for me.
Three layers: text, syntax, meaning
When TypeScript processes your code, it builds up information in layers:
- The parser turns text into a tree of nodes – the AST. A node knows what is written and where (
ClassDeclaration,Parameter,StringKeyword, …). That's purely syntactic. A node has no idea whatFooinx: Fooactually is. - The binder walks the tree and creates symbols. A symbol is the semantic identity behind a name: "there is a class called
SomeClass, with the membersvalueandfoo". Symbols point back to their declarations (the nodes). And here is the key trick: multiple declarations can share one symbol. That's how declaration merging works – an interface and a class with the same name, two interfaces in two files, a namespace that extends a function. In the AST these are unrelated nodes; in the symbol table they are one thing. - The checker computes types from symbols and nodes – lazily, on demand, and cached. It resolves imports and aliases, follows
export { a as b }, and infers what nobody wrote down.
This second part – symbols and types – is what I mean by the "semantic tree" that sits next to the AST. It is why TypeScript can do what it does: completions, refactorings, error messages, and .d.ts emit all depend on it. The syntax alone is not enough: export * from './foo' tells you nothing about what is exported until something resolves ./foo, finds its symbols, and merges them in.
Nodes vs. symbols vs. type nodes vs. types
The naming is confusing, so let's pin it down using a class from dets' own test fixtures:
-
Node (
ts.Node): one piece of syntax. Everything you can point at in the source. -
Type node (
ts.TypeNode): a node that is a type annotation –string,Foo<T>,A | B,{ x: number }. It is syntax: it exists only if someone wrote it.[1, 2]has no type node, but it very much has a type. -
Symbol (
ts.Symbol): the named entity the binder created. Carries flags (Class,Interface,Alias, …), itsdeclarations, and itsmembers/exports. -
Type (
ts.Type): what the checker knows.foo()above returnsstring, even though that's written nowhere.
The reason this matters for dets: the output is syntax. A .d.ts is text, so we need type nodes to print – but for inferred things, the checker only gives us types. The bridge is checker.typeToTypeNode(type), which converts semantics back into syntax. That one function is the reason dets works at all for code like foo() { return this.value; }.
A tiny example of the three APIs working together (this is not dets code, just the shape of things):
import ts from 'typescript';
const program = ts.createProgram(['src/index.ts'], { strict: true });
const checker = program.getTypeChecker();
const file = program.getSourceFile('src/index.ts')!;
ts.forEachChild(file, (node) => {
if (ts.isClassDeclaration(node) && node.name) {
// syntax: what is written
console.log(ts.SyntaxKind[node.kind]); // "ClassDeclaration"
// meaning: the symbol behind the name
const symbol = checker.getSymbolAtLocation(node.name)!;
console.log(symbol.declarations?.length); // 2+ if merged
// knowledge: the type (and back to a printable type node)
const type = checker.getTypeAtLocation(node);
const typeNode = checker.typeToTypeNode(type, undefined, ts.NodeBuilderFlags.NoTruncation);
}
});
From options to program
Where does the program come from? In dets, generateDeclaration (in src/commands.ts) is the conductor. Roughly:
- Collect the source files (
files, the files of theapis, and thetypesentries – minus whatever isexcluded). - Create a
ts.Programwith a small, fixed set of compiler options: JS allowed,esModuleInterop, ESNext modules, React JSX – andBundlermodule resolution if the installed TypeScript is 5.3 or newer,Node10otherwise. (That's the "keeping up with TypeScript versions" part, in one line.) - Fill a visitor context with the exports that should be part of the result – from an exported object (
apis) or from all exports of a module (types). - Run the plugin phases around the visitor:
before-init→ create the visitor →before-process→processQueue()→after-process→before-stringify. - Stringify the result into the final declaration.
Everything interesting happens in step 4.
The most important file: src/input/node.ts
If dets has a heart, it is src/input/node.ts. It contains the DeclVisitor: the class that walks declarations and converts them into an intermediate model (plain objects describing interfaces, classes, aliases, functions, …) that the output stage can print.
A work queue, not a recursive walk
The visitor starts with the exports from the context and puts them in a queue. processQueue drains the queue; every declaration goes through processNode, which is basically a big dispatcher:
// simplified
processNode(node: ts.Node) {
this.processed.push(node); // remember first, handle later
if (ts.isTypeAliasDeclaration(node)) {
this.includeInContext(node, () => this.getAlias(node));
} else if (ts.isFunctionDeclaration(node)) {
this.includeInContext(node, () => this.getFunctionDeclaration(node));
} else if (ts.isInterfaceDeclaration(node)) {
this.includeExportedInterface(node);
} else if (ts.isClassDeclaration(node)) {
this.includeInContext(node, () => this.getClass(node));
} else if (ts.isEnumDeclaration(node)) {
this.includeInContext(node, () => this.getEnum(node));
} else if (ts.isExportDeclaration(node)) {
// export { a as b } vs. export * from './x'
} else {
this.printWarning('type', node); // we don't know this one (yet)
}
}
The key design decision: a reference is a name, not a body. When a type mentions another type (foo: Bar), the visitor does not descend into Bar right there. It enqueues the declaration of Bar and puts a reference-by-name into the model. That has two nice consequences:
- Every declaration is emitted exactly once, no matter how often it's referenced.
- Cyclic types (
AreferencesB,BreferencesA) terminate for free.
That is also how "tree shaking" for types works: if nobody references it from the entry, it never gets into the queue.
Walk the nodes, ask the checker only when needed
Most of the file is getTypeNode: a long chain of ts.isX guards that routes each type node to a handler – unions, literals, arrays, type references, indexed access, keyof/readonly, mapped types, conditional types, function and constructor types, infer, intersections, tuples (and named/optional/rest members), typeof queries, template literal types, and imports. Keywords like string or unknown are handled as constants.
Interestingly, the file does not switch on ts.Type flags. dets mostly walks the syntax and only reaches for the checker when the syntax doesn't say enough. The typical case is a missing annotation:
// simplified
getReturnType(node: ts.SignatureDeclaration) {
if (node.type) {
return this.getTypeNode(node.type); // written down – just use it
}
// not written down: ask the checker what it inferred
const signature = this.checker.getSignatureFromDeclaration(node);
const returnType = this.checker.getReturnTypeOfSignature(signature);
const typeNode = this.checker.typeToTypeNode(returnType, undefined, ts.NodeBuilderFlags.NoTruncation);
return this.getTypeNode(typeNode);
}
(NoTruncation is important – by default TypeScript abbreviates large types with ..., which is great for hover tooltips and terrible for a declaration file.)
The same pattern shows up for variables (annotation, then initializer, then inferred type), default exports, and typeof queries on imported values, which are expanded into synthetic interfaces.
Where symbols come in: merging, aliases, names
Symbols earn their keep in three places.
Declaration merging. When dets handles an interface, it asks the checker for the type's symbol and collects all interface declarations of that symbol – skipping those that come from imported packages. Heritage clauses, members and type parameters of all declarations are combined (without duplicates) into one interface. Classes do the same with interfaces that share the class symbol. That's the scenario where @dets_removeprop and @dets_removeclause come in handy.
Export aliases. For export { a as b }, the visitor resolves the specifier to the actual symbol (getExportSpecifierLocalTargetSymbol, getAliasedSymbol) and then renames the result.
Names. Two different declarations may have the same name – from different files, or an import that collides with a local type. All declarations of one symbol share one name, and a collision gets a ___N suffix:
// simplified
createName(name: string) {
const prefix = `${name}___`;
const taken = new Set([...this.names.values()].filter((n) => n === name || n.startsWith(prefix)));
return taken.size > 0 ? `${prefix}${taken.size}` : name;
}
Yes, a type called Foo___1 in your declaration file is slightly ugly. It is also correct, which beats the alternative.
Externals
Not everything should be bundled in. If a type comes from an external package, it's not copied: includeInContext checks whether the symbol belongs to a package (via a helper called getPackage), and if so, it becomes an import instead of part of the model. That's what the --imports option controls.
When it doesn't know
Whatever the visitor does not understand ends up in a warning (Could not resolve type at position …) rather than a crash. The log levels (0.8.0) let you decide how loud that should be. When you see such a warning on your code: that's a new test case waiting to happen.
How testing works in dets
A tool whose job is to produce exact text from arbitrary code has a natural test strategy: feed it small pieces of code and compare the output to the expected text.
The setup is refreshingly boring:
-
Fixtures.
tests/assets/contains ~150 small TypeScript files:class1.ts,interface1.ts,merge1.ts,enum1.ts,react1.tsx,vue1.ts, … Each one isolates a single feature or bug. -
A helper.
runTestFor(name, options)calls the realgenerateDeclarationwith sensible defaults: module nametest, the assets folder as root, and the fixture as bothfilesandtypes. Options can be overridden per test. -
Tests. About 30 test files (
classes.test.ts,merge.test.ts,tuple.test.ts,typeof.test.ts, …), run with Vitest. Each test runs the helper on a fixture and compares the result to a template literal.
Here is what the simplest one looks like. The fixture:
export class SomeClass {
public constructor(private value: string) {}
foo() {
return this.value;
}
}
And the test – abbreviated (the repo has the exact expected text), with the expectation being the full text of the generated declaration:
import { test, expect } from 'vitest';
import { runTestFor } from './helper';
test('should be able to handle exported classes with constructor', async () => {
const result = await runTestFor('class1.ts');
expect(result).toBe(`declare module "test" {
export class SomeClass {
private value;
public constructor(value: string);
foo(): string;
}
}
`);
});
Note the foo(): string; – that is the inferred return type from the checker, making a round trip through typeToTypeNode.
A few things I like about this approach:
- It's a real end-to-end test. No mocking of the compiler. Every test creates an actual program and uses the actual checker – which is exactly where the bugs live.
-
Exact comparison (
toBe). No normalization of whitespace. The declaration file is a product; indentation and order are part of it. -
Every bug gets a fixture. Titles like "should treat readonly fields correctly (issue 55)" are a diary of the project. The 14 tests in
classes.test.tsalone cover constructors, modifiers, generics (explicit and implicit), implemented interfaces, React classes from externals and bundled in, static members, accessors, readonly, and class/interface merging. -
Real-world typings. The helper can also point at the typings of
piral-baseandpiral-core(the original use case!), and the dev dependencies read like a who's who of typings: React, Redux, React Router, styled-components, Vue. If it can handle those, it can handle your code.
The CI (GitHub Actions) runs the tests and the build on every push to main or develop and for pull requests to develop. A push to develop publishes a pre-release to npm under the next tag (with the run number in the version); a push to main publishes the real thing – with provenance – and creates the git tag. The build script even runs dets on itself: the shipped dist/index.d.ts is bundled by dets, with typescript as the external import. If the tool can't describe its own API, that's a red flag.
One more detail from the changelog: in 0.16.5 the tests moved from Jest to Vitest – a good reminder that even a test suite is just another dependency to keep alive.
The changelog: a tour of 1.0
With about 60 changelog entries, the CHANGELOG tells the story better than I could. I find it easiest to read in four themes.
Teaching it the language
The early releases are a march through the language: 0.1 brought classes, generics and constructor calls (and extends/implements came in 0.1.5). 0.2.0 was the big one: a rewrite of the engine for better resolution and accuracy, with support for dynamically placed imports and destructuring. 0.3.0 added interface merging resolution, tuples and enums, 0.3.2 accessors (get/set) and readonly. Later highlights: overloads and the satisfies operator (0.12), named tuples (0.11.2, 0.12.3), triple-slash directives (0.14.1), the spread operator (0.16.3), and in 1.0.0 optional tuple entries.
Control
Once the output was right, people wanted to shape it. 0.8.0 added log levels with custom log providers. 0.9.0 introduced @ignore handling and the @dets_preserve, @dets_removeprop and @dets_removeclause tags. 0.10.0 added lifecycle plugins (and declaration merging for externals). 0.12.0 added the module-declaration option, 0.14.0 a modern plugin API with multiple lifecycle hooks (and made the API async), and 0.18.0 the excluded option.
Keeping up with TypeScript
The thread that runs through the whole history. TypeScript became a peer dependency in 0.4.0, 0.7.0 added TypeScript 4, 0.11.0 added guards for older versions, 0.13.0 added TypeScript 5 and dropped TypeScript 3, and 0.14.1 fixed TypeScript 5.3 not considering externals. 1.0.0 finally improved the version independence – and fixed importing types from hidden submodules.
Housekeeping
Bundling moved from ncc to esbuild (0.13.0), Node 14, 16 and 18 were dropped step by step (0.15, 0.17, 0.18) – the minimum is Node.js 20 – the tests moved to Vitest (0.16.5), and publishing now uses npm's trusted publishing via GitHub Actions (0.18.0). Plus a long tail of "fixed X" entries, many triggered by issue reports: dots in names (0.16.5, thanks to @longlostbro), readonly in constructors (0.17.0), and more.
And then, finally, 1.0.0: the version number that says "this has been around long enough".
Where dets shines
- API subsets. Exactly the use case from above: take a big application, expose only the part that's meant for others.
-
One file. Combining all the
.d.tsfiles of a library into a single one – especially if the library is bundled and compressed anyway. It's tidy. - Controllable. Special comments, plugins and a programmatic API let you adjust the result without post-processing strings.
- TypeScript-version independent. Over the years it moved from TypeScript 3 through 4 to 5 and the 1.0.0 release improved version independence further.
Where dets struggles
Let's be honest:
- It stands on internals. I use TypeScript's compiler API – the part that's intentionally under-documented and not stable. A TypeScript release can (and sometimes does) break things. The changelog is full of entries like "fix for TypeScript 5.3" for exactly this reason.
- Type extraction is hard. Complex type-level programming, odd declaration merging, and exotic constructs may need a plugin or a comment hint to come out right.
- Small project. There are a few dozen stars and a small group of users. It does one thing, and there's not much of a community around it.
- Documentation. The README covers the options, but it is not a book. For the more advanced scenarios the best documentation is the source and the tests.
dets today
In the Piral world, dets is used in two places:
- For the emulator typings of Piral: the app shell's API is turned into a declaration file that ships with the emulator, so micro frontend developers get completion and type checks locally.
- For libraries in the ecosystem: combining all the
.d.tsfiles into one – the nicest fit for libraries that are bundled / compressed anyway.
Beyond that, the npm download counter shows about 7,000 downloads per week (12–18 October 2026: 7,215) – more than I'd have guessed for a tool that started as an internal helper.
What's next: TypeScript 7
The big question mark: TypeScript 7. The compiler is being ported to a native implementation, and with that, the future of the TypeScript API as we know it is still to be determined. I am sure that an API will return at some point – too many tools depend on it, and it would be strange to not offer a way to build on top of the compiler.
Until then, the plan is simple: we stay with TypeScript 6 (or 5) and are happy to keep on bundling. If dets needs an adaptation once the new API shapes up, I'll make it – and write about it, probably.
Links
- 💻 GitHub: FlorianRappl/dets
- 📦 npm: dets
Previous articles in this series:
Next up in this series
One more project to go after this one – and it's a big one. Stay tuned!











