Skip to content

Path aliases not resolved in DTS output with unbundle: true #833

@mazipan-wego

Description

@mazipan-wego

Clear and concise description of the problem

When using unbundle: true with the alias option, path aliases are correctly resolved in the JS output (.mjs / .js) but remain unresolved in the DTS output (.d.ts / .d.mts).

This breaks consumers of the library because they do not have the same tsconfig path aliases configured, so TypeScript cannot resolve the imports from the declaration files.

Reproduction

tsconfig.json

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "~src/*": ["src/*"],
      "~utils/*": ["src/utils/*"]
    }
  }
}

tsdown.config.ts

import path from 'node:path';
import { defineConfig } from 'tsdown';

export default defineConfig({
  entry: ['src/**/*.ts'],
  format: ['esm', 'cjs'],
  dts: true,
  unbundle: true,
  alias: {
    '~src': path.resolve('src'),
    '~utils': path.resolve('src/utils'),
  },
  deps: {
    neverBundle: (id) => !id.startsWith('.') && !id.startsWith('~') && !id.startsWith('\0'),
  },
});

src/apis/getFoo.ts

import type { DefaultHeaders } from '~src/types';

export const getFoo = (params: DefaultHeaders) => fetch('/foo');

Expected output (dist/apis/getFoo.d.ts)

import { DefaultHeaders } from "../types";  // resolved relative path ✅

Actual output (dist/apis/getFoo.d.ts)

import { DefaultHeaders } from "~src/types";  // unresolved alias ❌

Root cause

The alias option feeds into Rolldown's JS resolution pipeline. However, the TypeScript DTS emitter runs as a separate step and is not aware of the alias config — it preserves the original import specifiers as written in the source.

This means the two outputs (JS and DTS) are inconsistent: JS imports work correctly at runtime, but DTS imports break TypeScript consumers of the published package.

Current workaround

A post-build script that walks dist/ and rewrites alias specifiers to relative paths:

// scripts/fix-dts-aliases.mjs
import { readdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, relative, resolve } from 'node:path';

const DIST_DIR = resolve(process.cwd(), 'dist');

const ALIASES = [
  ['~utils', 'utils'],
  ['~src', ''],
];

async function* walkDts(dir) {
  for (const entry of await readdir(dir, { withFileTypes: true })) {
    const full = `${dir}/${entry.name}`;
    if (entry.isDirectory()) yield* walkDts(full);
    else if (entry.name.endsWith('.d.ts') || entry.name.endsWith('.d.mts')) yield full;
  }
}

async function fixFile(filePath) {
  const original = await readFile(filePath, 'utf8');
  const fileDir = dirname(filePath);

  const fixed = ALIASES.reduce((content, [alias, targetBase]) => {
    return content.replace(new RegExp(`"${alias}(/[^"]*)?"`,'g'), (_, sub = '') => {
      const subPath = sub.startsWith('/') ? sub.slice(1) : sub;
      const absTarget = targetBase ? resolve(DIST_DIR, targetBase, subPath) : resolve(DIST_DIR, subPath);
      let rel = relative(fileDir, absTarget);
      if (!rel.startsWith('.')) rel = `./${rel}`;
      return `"${rel}"`;
    });
  }, original);

  if (fixed !== original) await writeFile(filePath, fixed);
}

for await (const file of walkDts(DIST_DIR)) await fixFile(file);

Suggested solution

The alias config (or at minimum tsconfig paths) should be applied to the DTS generation step as well, so aliases are resolved to relative paths in the declaration output. This would be consistent with how tools like tsc-alias or typescript-transform-paths handle this for the TypeScript compiler.

Environment

  • tsdown: 0.21.2
  • typescript: 5.9.3
  • node: v24.14.0

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions