TypeScript adapter
from callix import TypeScriptAdapter
graph = TypeScriptAdapter().analyze("path/to/ts-project")
TypeScriptAdapter(resolve=False).analyze(root) # structure only
| Property | Value |
|---|---|
| Language id | typescript |
| Project markers | package.json, tsconfig.json |
| Extensions | .ts, .tsx, .mts, .cts |
| Resolver | embedded typescript-go |
| External requirements | none |
Declaration files (.d.ts, .d.mts, .d.cts) are skipped: they carry types
without implementation. A call into a declaration file stays unlocated.
What it extracts
Classes, interfaces and their members, enums, type aliases, functions and
arrow functions, variables from const/let declarations, and imports and
re-exports — including export { X } from 'mod', export * from 'mod' and
export * as N from 'mod', whose module string sits directly on the export
statement rather than in a from clause.
Import and export specifiers and module strings are placed: an internal import
specifier has a RESOLVES_TO from its IMPORT to the definition (positioned at
the imported name, not the alias), and a relative module string has a
REFERENCES edge from the file to the imported file's MODULE. A local
export { x } references x. Only a relative module string is placed: a path
alias such as "@/store" gets no module-string edge (its specifiers are still
placed), and external specifiers and module strings get no edge.
Module names and path aliases
A file path becomes a dotted module name: src/pkg/ui.tsx → pkg.ui. Files
named index represent the package itself.
compilerOptions.paths is honoured. tsconfig.json is parsed as JSONC —
// comments and trailing commas included, because that is how the file is
written in practice. An alias is rewritten before the module name is computed,
so @/client/v2 → src/client/v2 → client.v2, which lines up with the names
derived from file paths.
Dependency classification
dependencies, devDependencies, peerDependencies and
optionalDependencies are all read, so imports from tests and peer
dependencies classify as third_party rather than unknown. Node's built-in
modules are recognized with and without the node: scheme.
Boundaries
Express routes and NestJS @Get(...) controller methods on the server side;
fetch and axios on the client side; queue producers and consumers.
A fetch(url, {method: "POST"}) is read as POST — the literal wins over the
GET default, so a non-GET request does not enter the graph as a GET.
Members and object-literal keys
A property read (s.kind) and a property write (s.size = 3) give a REFERENCES
edge to the property they name, with access read or write, when the type
checker can place it (an interface member, a class field, a method). A property
of a library type becomes an external symbol, as a library call does.
The key of an object literal, and the name of a method written in one, references
the member of the type the literal is checked against (const h: Shape = { kind: 1 },
a returned literal, {...} as Shape); its access is read. The key of an
untyped literal declares the property and gives no edge, and a key typed by a
library interface gives none either.
Not covered: element access (o["k"], o[k]), shorthand keys ({ x } references the
local), computed keys, and x.count++ as a write. Members of a type alias's object type
(also in the arms of its top-level union or intersection) are nodes Alias.member, like
an interface's. An inline type literal elsewhere (a parameter, a variable, a generic
argument), an object type nested in a member's type and a computed member name have no
stable qualified name and no node, so reads of them are unlocated. A string or number
member name ("~standard": T, 0: T) is a node named without its quotes.
Namespaces. A namespace N { ... } (also export namespace, declare namespace
and namespace A.B, which gives the MODULEs A and A.B) is a MODULE <scope>.N with
metadata["is_namespace"], the extent of its declaration as span, and a DECLARES edge
from its container; what it declares is N.member. A call written directly in the body
of a namespace belongs to the namespace's MODULE. The function signatures of an
ambient namespace (declare namespace D { function h(x: number): void; }) are FUNCTIONs
whose declared_at lists every signature. A second declaration of the same namespace,
interface or member in the same file adds a declared_at span, not a node. A namespace
that shares a dotted name with a file or directory module is not a node of its own, and
neither are declare global and a string-named declare module "x".
A method or accessor of an object literal that is not a variable's initializer (a call argument, a returned or nested literal) is an inline closure: its calls belong to the enclosing function, as a callback's do. A comment in front of an initializer (/*@__PURE__*/) does not affect what is scanned.
Type positions
Every name a type mentions is a use, not only the first identifier. In a declaration
position it is a HAS_TYPE edge: a variable, field or parameter annotation, a return
type, the body of a type alias, and the constraint or default of a type parameter
(a.T, Array<Shape>, Shape | a.T, Shape["k"], { s: Shape } each give an edge per
name). The type of a.T is T; the namespace a and the operand of typeof v are
REFERENCES with access read.
Where the type is not the type of a declaration it is a REFERENCES edge: the type
arguments of a call or new (f<Shape>(x)), x as T, x satisfies T, <T>x, f<T>,
and the type arguments and namespace prefix of an extends clause. An interface that
extends a qualified name (interface J extends a.Shape) now inherits from it.
A type parameter, a mapped-type key ([k in keyof S]) and an infer name are not nodes
and give no edge; neither do the parameter names inside a function type.
Not covered: implements clauses, the annotations of inline arrow-function parameters,
and JSDoc types in .js files.
The embedded typescript-go
The resolver is built on
typescript-go, the official Go
port of the TypeScript compiler, linked into the module statically together
with lib.d.ts. Neither tsc nor tsgo nor Node is needed.
It can also be used directly:
from callix import TsResolver
resolver = TsResolver()
resolver.prepare("path/to/ts-project") # finds tsconfig.json itself
resolver.definition_at("src/main.ts", 4, 15)
The first query builds the program and takes tens of milliseconds; each one after that is tens of microseconds.
Overloads
An overload signature is not a node. The implementation is the FUNCTION, and its
declared_at lists every signature, also for export, export default and
namespace members, so a call that the checker resolves to a signature lands on the
implementation. A file-level declare function or export declare function has no
implementation: it is a FUNCTION placed at its first signature, with every signature
in declared_at. Signatures inside declare global { ... } give no node.