Skip to content
loclizr
dist/index.d.ts
export { dialog_cancel, dialog_title } from './loclizr/messages';
export declare function confirmDelete(count: number): string;

A library built with Rollup plus tsc --emitDeclarationOnly publishes this file and no dist/loclizr. tsc never copies a hand-written .d.ts into outDir, and the generated tree is .js plus a printed .d.ts, never .ts. What tsc -p . in the app reports depends on two of its settings:

App skipLibCheck App moduleResolution tsc -p . in the app
false bundler node_modules/@acme/ui/dist/index.d.ts(1,45): error TS2307: Cannot find module './loclizr/messages' or its corresponding type declarations.
false nodenext node_modules/@acme/ui/dist/index.d.ts(1,45): error TS2834: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Consider adding an extension to the import path.
true, Vite’s default either exit 0: dialog_title is any, so dialog_title({ count: '1' }) passes

The fix is to ship the generated declarations. Every block here ran against a library @acme/ui with two messages (dialog.title, dialog.cancel, in en and de), packed with npm pack and installed in an app with its own tree (en, de, fr).

  1. loclizr.config.ts
    import { defineConfig } from 'loclizr'
    export default defineConfig({
    locales: ['de', 'en'],
    sourceLocale: 'en',
    augmentLocale: false,
    })

    Left on, the shipped messages.d.ts augments LocaleRegistry beside the app’s own tree: TS2717 inside node_modules when the two locale lists differ and the app has skipLibCheck off. See Two trees in one program.

  2. Import the tree with .js and export messages by name

    Section titled “Import the tree with .js and export messages by name”
    src/index.ts
    import { dialog_title } from './loclizr/messages.js'
    export { dialog_cancel, dialog_title } from './loclizr/messages.js'
    export function confirmDelete(count: number): string {
    return dialog_title({ count })
    }

    tsc copies the specifier into dist/index.d.ts as written. Without .js, an app on moduleResolution: "nodenext" fails inside the package:

    node_modules/@acme/ui/dist/index.d.ts(1,45): error TS2834: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Consider adding an extension to the import path.
  3. rollup.config.js
    import typescript from '@rollup/plugin-typescript'
    export default {
    input: 'src/index.ts',
    output: { file: 'dist/index.js', format: 'es' },
    external: ['loclizr'],
    plugins: [typescript({ declaration: false })],
    }

    Rollup inlines the generated .js and leaves the runtime import in place:

    dist/index.js
    import { $configure1, $plural1, $number1 } from 'loclizr';
  4. tsconfig.json
    {
    "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "declaration": true,
    "rootDir": "src",
    "outDir": "dist",
    "skipLibCheck": true
    },
    "include": ["src"]
    }
  5. scripts/copy-loclizr-types.mjs
    import { cpSync } from 'node:fs'
    import { basename } from 'node:path'
    // tsc never copies a hand-written .d.ts into outDir, and Rollup has already
    // inlined the generated .js, so only the declarations are left to ship.
    cpSync('src/loclizr', 'dist/loclizr', {
    recursive: true,
    filter: (path) => !path.endsWith('.js') && basename(path) !== '.gitignore',
    })

    The filter skips the tree’s self-ignoring .gitignore. Copied into dist/loclizr, it makes npm pack ship that directory as the .gitignore alone.

  6. package.json
    {
    "name": "@acme/ui",
    "version": "1.0.0",
    "type": "module",
    "exports": {
    ".": {
    "types": "./dist/index.d.ts",
    "default": "./dist/index.js"
    }
    },
    "files": ["dist"],
    "scripts": {
    "build": "loclizr build && rollup -c && tsc -p tsconfig.json --emitDeclarationOnly && node scripts/copy-loclizr-types.mjs"
    },
    "peerDependencies": {
    "loclizr": "^0.1.2"
    },
    "devDependencies": {
    "@rollup/plugin-typescript": "^12.3.0",
    "loclizr": "^0.1.2",
    "rollup": "^4.64.5",
    "tslib": "^2.8.1",
    "typescript": "^5.9.3"
    }
    }

npm pack now ships the tree’s declarations beside the bundle:

npm notice Tarball Contents
npm notice 131B dist/index.d.ts
npm notice 1.3kB dist/index.js
npm notice 461B dist/loclizr/messages.d.ts
npm notice 162B dist/loclizr/messages/_formats.d.ts
npm notice 282B dist/loclizr/messages/_locale.d.ts
npm notice 390B dist/loclizr/messages/dialog.d.ts
npm notice 568B package.json
src/wrong.ts
import { dialog_title } from '@acme/ui'
dialog_title({ count: '1' })
src/wrong.ts(3,16): error TS2322: Type 'string' is not assignable to type 'number'.

The same line fails with skipLibCheck on or off, and under moduleResolution bundler or nodenext (TypeScript 7.0.2 in the app).

When tsc compiles the library itself, dist/index.js imports ./loclizr/messages.js at run time, so the generated .js ships too:

scripts/copy-loclizr.mjs
import { cpSync } from 'node:fs'
import { basename } from 'node:path'
cpSync('src/loclizr', 'dist/loclizr', {
recursive: true,
filter: (path) => basename(path) !== '.gitignore',
})
package.json
"build": "loclizr build && tsc -p tsconfig.json && node scripts/copy-loclizr.mjs"
Build dist/loclizr holds typescript
Rollup plus tsc --emitDeclarationOnly .d.ts only 5
tsc alone .js and .d.ts 5 or 7

Steps 1 and 2 apply to both.

The generated code imports the runtime from loclizr, so the library lists it in peerDependencies and keeps it external, and the app installs it. Those $ helpers are versioned by the abi digit: start the peer range at the version that built the tree.

The library has no store of its own; it shares the app’s. Its message functions read the app’s locale, and fall back to their own source locale for one they lack. Run in the app, after m.setLocale(locale) from the app’s tree:

loclizr: a second generated directory registered [de, en]; the first registration keeps getLocale().
de | Deine Dateien | 2 Dateien löschen? | 1 Datei löschen?
fr | Vos fichiers | Delete 2 files? | Delete 1 file?

The first line is the library’s tree registering after the app’s. Outside production it prints whenever the library’s locales, sourceLocale or cookie differ from the app’s, so it appears in this correct setup too, and neither the library nor the app has an option to silence it.

Below it, the columns are getLocale(), the app’s own message, then two library calls. Inside runWithLocale('de', ...) from loclizr/server the library renders 2 Dateien löschen? as well. A second copy of loclizr nested under the library attaches to the same store: setLocale('de') imported from that copy moved the app’s getLocale() and its messages too.

Side Must not Because
Library Export the barrel’s getLocale or setLocale They are typed with the library’s list. With the app on fr, the barrel’s getLocale() returned fr while typed 'de' | 'en'.
Library Call setLocale It moves the app’s locale.
Library Bundle loclizr or list it under dependencies The app picks the runtime version; a peer keeps one copy to pick.
App Import the library before its own tree The first registration keeps getLocale().

Imported first, the library’s list wins. With the app on fr, its own messages still render French while getLocale() falls back to the library’s source:

loclizr: a second generated directory registered [de, en, fr]; the first registration keeps getLocale().
en | Vos fichiers | Delete 2 files?

Import the app’s generated messages at the top of its entry module, before anything that imports the library.