Module export patterns including factory functions and public API design
The generator scaffolds the canonical src/index.ts. Public API surface is small on purpose: a factory function, the generated API + model types, and nothing else unless a real consumer needs it.
src/index.tsimport { <Class>Impl } from './<Class>Impl.js';
export * from '../generated/api/index.js';
export * from '../generated/model/index.js';
export function new<Class>() {
return new <Class>Impl();
}
Rules baked into that shape:
.js — ./<Class>Impl.js, ../generated/api/index.js, etc. Required by module: NodeNext.export default. The factory is export function new<Class>().generated/ — re-export everything the codegen produced. Consumers need ConnectionProfile, the API interfaces, resource types, enums. Cherry-picking causes consumer churn every time we add a model.The generator's factory has no explicit return type. That's fine — TypeScript infers <Class>Impl. Add a return type only if you also change what the factory returns (e.g. wrap it in something that satisfies an interface).
package.json wiringThe generator's package.template.json sets:
{
"type": "module",
"main": "dist/src/index.js",
"files": [
"dist",
"generated/api/manifest.json",
"api.yml",
"connectionProfile.yml"
]
}
"type": "module" — module is ESM. Non-negotiable."main" — dist/src/index.js because TypeScript's outDir: dist preserves the src/ prefix."files" — what ends up in the published tarball. Everything else (node_modules, test/, generated/api/index.* minus the manifest, hub-sdk/) stays local."exports" — not used at the top level today. The codegen-emitted hub-sdk uses exports, the module itself relies on main.connectionProfile.yml is in files only for connector modules; the generator omits it from package.template.json when --moduleType=plain.
The factory + generated wildcards cover ~95% of modules. Add the rest only when a real consumer needs it.
// Custom error class — only if you throw it and want consumers to catch by type
export { ServiceSpecificError } from './errors.js';
// Custom profile extension — only if the connection profile is augmented in code
export type { ServiceProfile } from './<Class>Client.js';
// Impl class — only if a consumer needs `instanceof` or wants to extend it
export { <Class>Impl } from './<Class>Impl.js';
Each addition needs:
.js extension on the relative importThese are implementation details; consumers should not depend on them:
<Tag>ProducerImpl) — internal<Class>Client) — internal, leaks axios typessrc/mappers/*) — internal, generated-type-shapedIf a test legitimately needs one of these, import the file directly:
// In test/unit/UserMapperTest.ts
import { mapUser } from '../../src/mappers/userMapper.js';
That keeps the file private to the module's tarball while still letting in-repo tests reach it.
This is what consumers see when they do import … from '@zerobias-org/module-<vendor>-<service>':
import { new<Class>, ConnectionProfile, <Tag>Api, <Resource> }
from '@zerobias-org/module-<vendor>-<service>';
const api = new<Class>();
const profile: ConnectionProfile = { /* … */ };
await api.connect(profile);
const tagApi: <Tag>Api = api.get<Tag>Api();
const resources: <Resource>[] = await tagApi.list<Resource>();
Everything in that snippet comes from the four exports above (factory + two wildcards). If a consumer needs anything else, that's a signal that either the API spec is missing an operation or we're exporting the wrong layer.
# Factory exists, named, prefixed `new`
grep -nE "^export function new[A-Z]" src/index.ts \
&& echo "✓ factory" || echo "✗ no factory function"
# No default exports
! grep -n "^export default" src/index.ts \
&& echo "✓ no default exports" || echo "✗ default export present"
# Generated re-exports present
grep -nE "export \* from ['\"]\.\./generated/(api|model)/index\.js['\"]" src/index.ts | wc -l
# Expect: 2
# ESM extensions on all relative imports
grep -rnE "from '\.\.?\/[^']*'" src/ | grep -v "\.js'" \
&& echo "✗ missing .js extension" || echo "✓ ESM imports OK"
Cannot find module './<Class>Impl' at compile or test time — missing .js extension.'newFoo' is not exported from … — factory was renamed or moved. Use new<Class> exactly; that's what the generator emits and what consumers/test fixtures grep for.ConnectionProfile — export * from '../generated/model/index.js' was removed or replaced with a cherry-pick.generated/api/manifest.json not in files. Restore the canonical files array.