In the ground since Sun Nov 16 2025
Last watered inSun Nov 16 2025
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
- Type declarations (.d.ts files)
- Proper exports (what's public vs private)
- 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:
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?
-
Bundlers can optimize better - They know FeatureFlag doesn't need to be in the runtime bundle
-
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
- 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:
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:
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:
This catches type regressions when you refactor!
Key Takeaways
- Declaration files (.d.ts) are your library's public contract
- vite-plugin-dts simplifies type generation for Vite libraries
- Generic types (FeatureFlag<T>) give flexibility without complexity
- export type vs export - use the right one for better optimization
- JSDoc comments provide IDE tooltips for better DX
- readonly properties prevent mutation bugs
- package.json exports field is the modern way to expose your library
- 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
-
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)
-
Why use FeatureFlag<T = unknown> instead of just FeatureFlag?
- Allows consumers to specify the value type while maintaining a safe default
-
When should you use export type vs regular export?
- Use export type for type-only exports (interfaces, types) to help bundlers optimize better
-
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:
- Testing Strategy for Libraries - Vitest, MSW, Testing Library
- Bundle Size Optimization - Keeping your library under size targets
- Monorepo Dependency Management - Workspace dependencies and build order