Guides
Publishing a library
Ship the generated declarations with the package, keep loclizr a peer, and leave the locale to the app.
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).
Build with Rollup
Section titled “Build with Rollup”-
Turn off the locale augmentation
Section titled “Turn off the locale augmentation”loclizr.config.ts import { defineConfig } from 'loclizr'export default defineConfig({locales: ['de', 'en'],sourceLocale: 'en',augmentLocale: false,})Left on, the shipped
messages.d.tsaugmentsLocaleRegistrybeside the app’s own tree: TS2717 insidenode_moduleswhen the two locale lists differ and the app hasskipLibCheckoff. See Two trees in one program. -
Import the tree with
Section titled “Import the tree with .js and export messages by name”.jsand export messages by namesrc/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 })}tsccopies the specifier intodist/index.d.tsas written. Without.js, an app onmoduleResolution: "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. -
Keep
Section titled “Keep loclizr external”loclizrexternalrollup.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
.jsand leaves the runtime import in place:dist/index.js import { $configure1, $plural1, $number1 } from 'loclizr'; -
Emit declarations with
Section titled “Emit declarations with tsc”tsctsconfig.json {"compilerOptions": {"target": "ES2022","module": "ESNext","moduleResolution": "bundler","strict": true,"declaration": true,"rootDir": "src","outDir": "dist","skipLibCheck": true},"include": ["src"]} -
Copy the generated declarations
Section titled “Copy the generated declarations”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 intodist/loclizr, it makesnpm packship that directory as the.gitignorealone. -
Wire the build
Section titled “Wire the build”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 Contentsnpm notice 131B dist/index.d.tsnpm notice 1.3kB dist/index.jsnpm notice 461B dist/loclizr/messages.d.tsnpm notice 162B dist/loclizr/messages/_formats.d.tsnpm notice 282B dist/loclizr/messages/_locale.d.tsnpm notice 390B dist/loclizr/messages/dialog.d.tsnpm notice 568B package.jsonProve it in the app
Section titled “Prove it in the app”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).
Build without a bundler
Section titled “Build without a bundler”When tsc compiles the library itself, dist/index.js imports ./loclizr/messages.js at run
time, so the generated .js ships too:
import { cpSync } from 'node:fs'import { basename } from 'node:path'
cpSync('src/loclizr', 'dist/loclizr', { recursive: true, filter: (path) => basename(path) !== '.gitignore',})"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.
loclizr is a peer dependency
Section titled “loclizr is a peer dependency”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.
What each side must not do
Section titled “What each side must not do”| 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.