The module defines a public boundary
In a file treated as a module, a local declaration is not automatically available to consumers. export identifies public names; import allows using names exported by another module. const prevents reassigning a binding but does not make it public. A module can have named exports without providing export default.
Define the boundary through what consumers need to do. A creation function can be public while storage details remain local. Changing an exported name or removing an externally used value changes the contract even if every use inside the defining file remains correct.
Types and values have different lifetimes
import type communicates that the binding is used only for type analysis. The entire statement disappears from emitted JavaScript and supplies no callable value. An exported function can be imported as a value for calling; marking it type-only and then calling it violates that contract.
With verbatimModuleSyntax, distinguish type imports explicitly from value imports. In the example’s ESM context, the createJob import remains and the Job import type is removed. This neither turns Job into a data validator nor justifies assuming external arguments were inspected.
Loading effects need a dependency
A module may register a handler when loaded. If its consumer retains only an import type for an interface from that module, the erased statement does not cause loading. When registration is required, an explicit side-effect import such as import "./register.js" communicates the runtime dependency.
Also consider exposing a registration function and calling it at a controlled point. The choice depends on the application contract, but neither solution should depend on an incidental type annotation. An additional type import does not establish that initialization happened.
The host determines how implementation is found
The example uses nodenext for module and moduleResolution, .ts files in a package with type: module, and direct execution of emitted .js files in Node ESM. In this context, ./model.js identifies the final relative file. TypeScript resolution can find the model.ts source through extension substitution.
Do not generalize this rule to every host. A bundler may accept extensionless paths and transform them, while direct Node ESM execution requires extensions on relative specifiers. .mts and .cts also make the format family explicit. Choose configuration from the actual environment and check the distributed-file contract.
paths does not publish the private resolver
paths helps the compiler interpret names during checking. It does not automatically rewrite those names in emitted JavaScript. If @app/model exists only in private repository configuration, a consumer receiving that import can fail before calling any function. An additional .d.ts does not teach the host to resolve the alias.
A distributed library needs relative or package specifiers consumers can resolve, or an explicitly included transformation stage in the delivered product. Review both distributed JavaScript and declarations. Fixing implementation imports alone can leave type consumers dependent on the same private alias.
Declarations describe an implementation
A .d.ts file supplies checking information and does not generate the JavaScript body of a declared function. Before looking for separate package types, check whether the distribution already bundles declarations. Declaration versions must match the version actually delivered, including asynchronous returns, names and exported shapes.
The shorthand declare module "legacy-client" may remove missing-type diagnostics but leaves consumers without a detailed contract. any can then conceal incompatible calls. If a declaration promises Promise<void> while implementation starts work without returning completion, the annotation does not make await sufficient to wait for that work.
Review the example from source to consumer
Read model.ts and app.ts as two files. createJob constructs the actual value and Job describes the return shape. prepareJob depends on the value import to call createJob. Both emitted files must retain a directory relationship compatible with ./model.js. A standalone declaration does not replace model.js in that delivery.
The signature accepts a string already available to its caller; it does not claim the string is a valid identifier under external rules. An HTTP or configuration boundary still needs the validation taught in the preceding lesson. Module review adds availability and resolution to the data contract without removing that contract.
// Separate files; TypeScript 7.0, strict and verbatimModuleSyntax enabled.
// module/moduleResolution: nodenext; package.json contains "type": "module".
// src/model.ts
export type Job = { id: string; attempts: number };
export function createJob(id: string): Job {
return { id, attempts: 0 };
}
// src/app.ts
import type { Job } from "./model.js";
import { createJob } from "./model.js";
export function prepareJob(id: string): Job {
return createJob(id);
}Read the two files separately. Job is needed only for checking; createJob is needed when prepareJob is called. The ./model.js specifier matches the emitted file although its source is model.ts. The example does not validate an external identifier.
Common pitfalls
Confusing .d.ts with implementation; trusting paths to rewrite imports; removing an import supporting an effect; using bundler resolution for a host that does not implement it.
Related topics: Functions and callable contracts · External data and error boundaries · Configuration, delivery and migration
A public contract needs faithful types, available values and names consumers can resolve. Review all three together.
Reference: TypeScript: how javascript modules are defined · TypeScript 7.0; explicit strict and host configuration