Mario Brusarosco

typescript for library packages

In the ground since Sun Nov 16 2025

Last watered inSun Nov 16 2025

Related Topics

Referece Links

Recap

Creating a TypeScript library that provides an amazing developer experience requires more than just writing TypeScript code. You need proper type declarations (.d.ts files), smart use of generics for flexibility, and correct package.json configuration so consumers get perfect autocomplete and IntelliSense.

Key Achievement: Understanding how to structure TypeScript types for library packages to provide the best possible developer experience for consumers.

The Goal: Great TypeScript Library DX

When someone installs your library, they should get:

1// ✅ Perfect autocomplete
2import { useFeatureFlag } from '@peek-a-boo/react-sdk';
3// ^ IDE suggests this as they type
4
5const flag = useFeatureFlag('new-checkout');
6// ^ IDE shows: const flag: FeatureFlag | null
7
8flag.
9// ^ IDE shows: .enabled, .value, .key, etc.

Three Ingredients for This Magic

  1. Type declarations (.d.ts files)
  2. Proper exports (what's public vs private)
  3. Package.json configuration (telling tools where types live)

Part 1: Declaration Files (.d.ts)

What Are They?

.d.ts files are like a "contract" - they describe your JavaScript's shape without implementation.

TypeScript source file:

1// src/client/types.ts
2export interface FeatureFlag {
3 key: string;
4 enabled: boolean;
5 value: unknown;
6}

Compiled JavaScript:

1// dist/client/types.js
2export {}; // Empty! Interfaces don't exist at runtime

Declaration file:

1// dist/client/types.d.ts
2export interface FeatureFlag {
3 key: string;
4 enabled: boolean;
5 value: unknown;
6}

The consumer only needs the .d.ts! Their TypeScript reads it, their IDE gets autocomplete.

Generating Declaration Files

Option 1: TypeScript Compiler

In tsconfig.json:

1{
2 "compilerOptions": {
3 "declaration": true, // Generate .d.ts files
4 "declarationMap": true, // Generate .d.ts.map (for "Go to Definition")
5 "emitDeclarationOnly": true, // Only output .d.ts (Vite handles .js)
6 "outDir": "dist"
7 }
8}

Build process:

1tsc # Generates .d.ts files in dist/
2vite build # Generates .js files in dist/

Option 2: vite-plugin-dts (Recommended)

Simpler approach:

1pnpm add -D vite-plugin-dts
1// vite.config.ts
2import { defineConfig } from 'vitest/config';
3import dts from 'vite-plugin-dts';
4
5export default defineConfig({
6 plugins: [
7 dts({
8 insertTypesEntry: true, // Creates index.d.ts entry point
9 rollupTypes: true // Bundles all .d.ts into one file
10 })
11 ],
12 build: {
13 lib: { /* ... */ }
14 }
15});

Now vite build does everything - .js AND .d.ts!

Why vite-plugin-dts?

  • One command builds everything
  • Handles complex scenarios (bundling types)
  • Less configuration
  • Better for libraries

Part 2: Structuring Your Types

Example: Feature Flag SDK Types

1// src/client/types.ts
2
3/**
4 * Environment where feature flag is active
5 */
6export type Environment = 'DEVELOPMENT' | 'STAGING' | 'PRODUCTION';
7
8/**
9 * Feature flag from the API
10 */
11export interface FeatureFlag {
12 /** Unique flag key (e.g., "new-checkout") */
13 key: string;
14
15 /** Human-readable name */
16 name: string;
17
18 /** Whether flag is enabled */
19 enabled: boolean;
20
21 /** Flag value (any JSON-serializable data) */
22 value: unknown;
23
24 /** Environment this flag belongs to */
25 environment: Environment;
26
27 /** Optional description */
28 description?: string;
29}
30
31/**
32 * API response when fetching all flags
33 */
34export interface FlagsResponse {
35 flags: FeatureFlag[];
36 environment: string;
37}
38
39/**
40 * Configuration for FeatureFlagClient
41 */
42export interface ClientConfig {
43 /** Project ID from Peek-a-boo dashboard */
44 projectId: string;
45
46 /** API endpoint (defaults to production) */
47 apiUrl?: string;
48
49 /** Environment to fetch flags for */
50 environment?: Environment;
51
52 /** Enable polling for flag updates (in ms, default: disabled) */
53 pollingInterval?: number;
54}

Notice:

  • JSDoc comments (/** ... */) - These show up in IDE tooltips!
  • Optional properties (?) - Makes API flexible
  • unknown type - Safer than any (forces type checking before use)

Part 3: Type vs Interface

When to Use Interface

Use interface for:

  • Object shapes
  • Things you might extend later
  • Public API types
1export interface FeatureFlag {
2 key: string;
3 enabled: boolean;
4}
5
6// Consumers can extend it:
7interface MyCustomFlag extends FeatureFlag {
8 customField: string;
9}

When to Use Type

Use type for:

  • Unions, intersections
  • Primitives, tuples
  • Computed types
1export type Environment = 'DEVELOPMENT' | 'STAGING' | 'PRODUCTION';
2export type FlagValue = string | number | boolean | object;
3export type Maybe<T> = T | null;

For Our SDK

1export interface FeatureFlag { /* ... */ } // ✅ Interface (extendable)
2export type Environment = 'DEV' | 'PROD'; // ✅ Type (union)
3export interface ClientConfig { /* ... */ } // ✅ Interface (extendable)

Part 4: Generic Types for Flexibility

Our flag value is unknown - but users often know its type!

The Problem

1const flag = useFeatureFlag('new-checkout');
2const config = flag.value; // Type: unknown 😞
3config.timeout // TypeScript error: unknown has no properties

The Solution: Generics

1// src/client/types.ts
2export interface FeatureFlag<T = unknown> {
3 key: string;
4 enabled: boolean;
5 value: T; // Generic type parameter
6 environment: Environment;
7}

Now users can specify the type:

1interface CheckoutConfig {
2 timeout: number;
3 retries: number;
4}
5
6const flag = useFeatureFlag<CheckoutConfig>('new-checkout');
7// ^^^^^^^^^^^^^^ Type parameter
8
9if (flag) {
10 flag.value.timeout // ✅ TypeScript knows this exists!
11}

Default behavior still works:

1const flag = useFeatureFlag('some-flag');
2// Uses default: FeatureFlag<unknown>

Hook signature:

1// src/react/hooks/useFeatureFlag.ts
2export function useFeatureFlag<T = unknown>(
3 key: string
4): FeatureFlag<T> | null {
5 // implementation...
6}

This pattern gives users flexibility without complexity.

Part 5: Type-Only Exports

Sometimes you export types that have NO runtime value:

1// src/client/types.ts
2export interface FeatureFlag { // Type-only
3 key: string;
4 enabled: boolean;
5}
6
7export class FeatureFlagClient { // Has runtime value
8 constructor(config: ClientConfig) {}
9 async getFlags() {}
10}

Best Practice: Use export type

1// ✅ Explicit type export
2export type { FeatureFlag, ClientConfig };
3
4// ✅ Runtime export
5export { FeatureFlagClient };

Why Does This Matter?

  1. Bundlers can optimize better - They know FeatureFlag doesn't need to be in the runtime bundle

  2. Consumers can re-export safely:

1// Consumer's code
2export type { FeatureFlag } from '@peek-a-boo/react-sdk';
3// Won't accidentally include runtime code
  1. Clearer intent - Reading the code, you know what's runtime vs compile-time

In Your Main Entry Point

1// src/index.ts
2export type {
3 FeatureFlag,
4 Environment,
5 ClientConfig,
6 FlagsResponse
7} from './client/types';
8
9export {
10 FeatureFlagClient
11} from './client/FeatureFlagClient';
12
13export {
14 FeatureFlagProvider
15} from './react/FeatureFlagProvider';
16
17export {
18 useFeatureFlag,
19 useFeatureFlags
20} from './react/hooks';

Part 6: Package.json Types Configuration

After building, you need to tell tools where your types live:

1{
2 "name": "@peek-a-boo/react-sdk",
3 "version": "0.0.1",
4 "type": "module",
5
6 "main": "./dist/index.cjs.js",
7 "module": "./dist/index.esm.js",
8 "types": "./dist/index.d.ts", // ← TypeScript looks here
9
10 "exports": {
11 ".": {
12 "types": "./dist/index.d.ts", // ← Modern way (preferred)
13 "import": "./dist/index.esm.js",
14 "require": "./dist/index.cjs.js"
15 }
16 }
17}

The exports Field (Modern Approach)

The exports field is the modern, recommended approach. It:

  • Maps import paths to actual files
  • Supports conditional exports (Node vs browser, dev vs prod)
  • Provides better encapsulation

Example with subpath exports:

1{
2 "exports": {
3 ".": {
4 "types": "./dist/index.d.ts",
5 "import": "./dist/index.esm.js"
6 },
7 "./client": {
8 "types": "./dist/client/index.d.ts",
9 "import": "./dist/client/index.esm.js"
10 }
11 }
12}

Allows:

1import { useFeatureFlag } from '@peek-a-boo/react-sdk'; // Main export
2import { FeatureFlagClient } from '@peek-a-boo/react-sdk/client'; // Subpath

Part 7: Ensuring Great DX

JSDoc Comments

TypeScript reads JSDoc comments and shows them in IDE tooltips:

1/**
2 * Fetches all feature flags for the configured project and environment.
3 *
4 * @returns Promise resolving to array of feature flags
5 * @throws {Error} If projectId is not configured
6 * @throws {Error} If API request fails after retries
7 *
8 * @example
9 * ```typescript
10 * const flags = await client.getFlags();
11 * console.log(flags.length); // Number of flags
12 * ```
13 */
14export async getFlags(): Promise<FeatureFlag[]> {
15 // implementation
16}

When hovering over client.getFlags() in VS Code, users see the full comment!

Best practices:

  • Document public API methods
  • Include @example for complex usage
  • Document @throws for error cases
  • Keep it concise (2-4 lines usually enough)

Readonly Properties

Prevent consumers from mutating library internals:

1export interface FeatureFlag {
2 readonly key: string; // Can't reassign
3 readonly enabled: boolean;
4 readonly value: unknown;
5}

Why?

1const flag = await client.getFlag('new-checkout');
2flag.enabled = true; // TypeScript error! ✅

Prevents bugs and makes your API's contract clearer.

Discriminated Unions for Error Handling

Instead of throwing errors, consider result types:

1export type Result<T, E = Error> =
2 | { success: true; data: T }
3 | { success: false; error: E };
4
5export async function getFlags(): Promise<Result<FeatureFlag[]>> {
6 try {
7 const flags = await fetch(/* ... */);
8 return { success: true, data: flags };
9 } catch (error) {
10 return { success: false, error: error as Error };
11 }
12}

Usage:

1const result = await client.getFlags();
2
3if (result.success) {
4 result.data // TypeScript knows this exists
5} else {
6 result.error // TypeScript knows this exists
7}

TypeScript enforces checking both cases!

Part 8: Testing Your Types

Type-Only Tests

You can test that your types work correctly using tsd:

1// src/client/__tests__/types.test-d.ts
2import { expectType, expectError } from 'tsd';
3import type { FeatureFlag } from '../types';
4
5// Test: FeatureFlag with generic works
6expectType<FeatureFlag<string>>({
7 key: 'test',
8 enabled: true,
9 value: 'hello', // Must be string
10 environment: 'DEVELOPMENT'
11});
12
13// Test: Wrong type should error
14expectError<FeatureFlag<string>>({
15 key: 'test',
16 enabled: true,
17 value: 123, // ❌ Not a string!
18 environment: 'DEVELOPMENT'
19});

Install and setup:

1pnpm add -D tsd
1{
2 "scripts": {
3 "test:types": "tsd"
4 }
5}

This catches type regressions when you refactor!

Key Takeaways

  1. Declaration files (.d.ts) are your library's public contract
  2. vite-plugin-dts simplifies type generation for Vite libraries
  3. Generic types (FeatureFlag<T>) give flexibility without complexity
  4. export type vs export - use the right one for better optimization
  5. JSDoc comments provide IDE tooltips for better DX
  6. readonly properties prevent mutation bugs
  7. package.json exports field is the modern way to expose your library
  8. Type-only tests with tsd catch regressions

Used in Projects

  • Peek-a-boo React SDK - Type-safe feature flag library with generics
  • Pattern applicable to any TypeScript library

Questions to Check Understanding

  1. What's the difference between .ts files and .d.ts files?

    • .ts files contain implementation code; .d.ts files only contain type declarations (the contract)
  2. Why use FeatureFlag<T = unknown> instead of just FeatureFlag?

    • Allows consumers to specify the value type while maintaining a safe default
  3. When should you use export type vs regular export?

    • Use export type for type-only exports (interfaces, types) to help bundlers optimize better
  4. How does the exports field in package.json differ from main and module?

    • exports is modern, supports conditions and subpaths; main/module are legacy single-entry fields

Next Steps

After understanding TypeScript for libraries:

  1. Testing Strategy for Libraries - Vitest, MSW, Testing Library
  2. Bundle Size Optimization - Keeping your library under size targets
  3. Monorepo Dependency Management - Workspace dependencies and build order