Creating Packages
Patterns for creating reusable, well-structured packages — whether in a monorepo packages/ directory or as standalone shared modules.
Contents
- •Follow Standard Package Structure
- •Point Types to Source Files for DX
- •Choose Entry Point Pattern by Complexity
- •Use Workspace References for Internal Dependencies
- •Build Client Packages as Thin Shims
- •Export Executable Endpoint Functions
- •Name Types by Function Coupling
- •Colocate Types with Function Files
- •Avoid Filtering Utilities in Client Packages
- •Export Only What the API Provides
Follow Standard Package Structure
packages/my-package/
├── src/
│ ├── index.ts # Main exports
│ ├── types.ts # Type definitions (if separate export needed)
│ └── {feature}.ts # One function per file
├── package.json
└── tsconfig.json
- •One function per file, matching our file naming convention (
{action}-{resource}.ts) - •
src/index.tsre-exports the public API - •Keep
README.mdonly for packages with complex usage
Why: Consistent structure makes packages predictable and easy to navigate across the codebase.
Point Types to Source Files for DX
In package.json, point types to source .ts files instead of compiled .d.ts:
{
"name": "@scope/my-package",
"private": true,
"main": "./dist/index.js",
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
}
}
Why: IDEs get full type information directly from source. No rebuild needed for type changes to propagate. Go-to-definition jumps to source code, not compiled output.
Choose Entry Point Pattern by Complexity
Single entry point — for simple packages (most cases):
// src/index.ts
export { getProducts } from './get-products'
export { createOrder } from './create-order'
export type { GetProductsArgs, GetProductsResponse } from './get-products'
Multiple entry points — for packages with distinct consumer groups (e.g., client vs server):
{
"exports": {
".": { "types": "./src/index.ts", "import": "./dist/index.js" },
"./server": { "types": "./src/server/index.ts", "import": "./dist/server/index.js" }
}
}
Why: Single entry point simplifies imports. Multiple entry points enable tree-shaking when consumers only need a subset.
Use Workspace References for Internal Dependencies
When packages depend on other internal packages, use workspace protocol:
{
"dependencies": {
"@scope/errors": "workspace:*",
"@scope/types": "workspace:*"
}
}
Why: Automatic version resolution within the workspace. Changes propagate immediately during development without publishing.
Build Client Packages as Thin Shims
API client packages are thin shims — they build requests, normalize responses, and provide types. Nothing more.
A client package should:
- •Build requests (URL construction, headers, params)
- •Normalize responses (snake_case to camelCase, field mapping)
- •Provide types for the API contract
- •Validate responses with Zod at the boundary
A client package should NOT:
- •Add business logic or abstractions beyond the API
- •Include convenience utilities that filter or transform responses
- •Combine multiple API calls into one function
- •Manage caching or state (that's React Query's job)
Why: Thin shims keep the API contract clear. Consumers understand exactly what API capabilities they're using.
Export Executable Endpoint Functions
Each API endpoint maps to one exported function. The function handles the full request lifecycle:
export async function getProducts(
config: GetProductsConfig,
args: GetProductsArgs,
fetchFn: typeof fetch = fetch,
): Promise<GetProductsResponse> {
const url = new URL(`${config.baseUrl}/products/${args.market}`)
url.searchParams.set('locale', args.locale)
const res = await fetchFn(url.toString())
if (!res.ok) {
throw await res.json()
}
return GetProductsResponseSchema.parse(await res.json())
}
Function signature pattern: endpoint(config, args, fetchFn = fetch)
- •
configfirst — environment values (baseUrl, API keys) that rarely change - •
argssecond — request-specific values (market, locale) that vary per call - •
fetchFnlast — injectable for logging or testing
Why: One call site, one place for response validation, consistent error shape. The injectable fetchFn enables server-side logging (e.g., loggedFetch) without coupling to implementation.
Name Types by Function Coupling
Types are named after the function they belong to:
| Type | Naming Pattern | Example |
|---|---|---|
| Request args | {FunctionName}Args | GetProductsArgs |
| Environment config | {FunctionName}Config | GetProductsConfig |
| Response | {FunctionName}Response | GetProductsResponse |
Args vs Config:
- •
Args— request-specific values that vary per call (market, locale, filters) - •
Config— environment values that stay constant (baseUrl, API keys)
Why: Consistent naming makes types discoverable. Separating config from args clarifies what changes per call vs what's fixed per environment.
Colocate Types with Function Files
Declare all types in the same file as the function they serve:
packages/payments-client/src/
get-customer.ts # Contains GetCustomerArgs, GetCustomerConfig, GetCustomerResponse + function
create-order.ts # Contains CreateOrderArgs, CreateOrderConfig, CreateOrderResponse + function
types/
errors.ts # Shared error types (one per client package)
- •Function-specific types live in the function file
- •Shared types (error shapes) go in
types/subdirectory - •Re-export everything from
src/index.ts
Why: Colocation eliminates hunting across files. Developers find the type right next to the function that uses it.
Avoid Filtering Utilities in Client Packages
Don't export convenience functions that filter or transform API responses:
// BAD: Don't do this in the client package
export function findProductBySku(
response: GetProductsResponse,
sku: string,
): Product | undefined {
return response.items.find((item) => item.variations.some((v) => v.sku === sku))
}
// GOOD: Let consumers filter in their own code
const { data } = useGetProductsQuery({ market, locale })
const product = data?.items.find((item) => item.variations.some((v) => v.sku === sku))
Why: Filtering utilities obscure API capabilities, imply server-side filtering that doesn't exist, and hide the real API contract from consumers.
Export Only What the API Provides
Don't create derived types in the client package. Consumers use indexed access types when they need sub-types:
// Consumer code — derive from API response
import type { GetProductsResponse } from '@scope/cms-client'
type Product = GetProductsResponse['items'][number]
type Policy = NonNullable<Product['policies']>[number]
Why: Origin of the type is explicit. Client package stays thin. Less maintenance when API types change.