Typing Revealing Module Pattern Implementations in TypeScript
Introduction
The revealing module pattern is a time-tested structure for encapsulating private state and exposing a clear public API. In JavaScript, it relies on closures to keep implementation details hidden while revealing the functions and data you want consumers to use. In TypeScript, we get the added power of static types — but we also face the challenge of modeling private state, typed public contracts, and safe composition without leaking implementation details.
This tutorial is aimed at intermediate TypeScript developers who already know the basics of functions, closures, and interfaces. We'll take the revealing module pattern from idiomatic JavaScript to a fully typed TypeScript implementation. You will learn how to:
- Model modules with precise public interfaces while preserving internal privacy
- Use factory functions with generics to build reusable modules
- Leverage ReturnType, type inference, and declaration helpers for ergonomics
- Combine modules safely using higher-order patterns
- Apply runtime guards and assertion functions to maintain type safety at boundaries
- Optimize internal caching and memoization strategies for performance
We'll include extensive, actionable code examples, step-by-step migration strategies, and tips on when to prefer classes or module singletons. Along the way, I'll point you to related, deeper topics like typing getters/setters, memoization strategies, and singletons so you can expand your toolset.
By the end of this guide you'll be able to design robust, typed revealing modules that are easy to use, testable, and safe to evolve.
Background & Context
The revealing module pattern is essentially a function or IIFE that returns an object exposing a subset of internal functions and values. It provides encapsulation without requiring classes. For libraries and utilities, revealed APIs can be more terse and dependency-free than class-based solutions. TypeScript can express these shapes and give you compile-time guarantees about the returned API, but naive approaches may leak implementation types or produce poor developer ergonomics.
This guide covers patterns for creating ergonomic typed module factories, how to hide details using TypeScript's type system (while recognizing its limits), and when to prefer other patterns such as class-based privacy or module-level singletons. We'll also show how typed modules integrate with memoization and caching strategies for real-world performance needs.
If you plan to mix module-style factories with class-based designs, check out the guide on typing private and protected class members to see how TypeScript models true class privacy and protected access.
Key Takeaways
- How to type a revealing module's public API using interfaces and ReturnType
- Use generic factory functions to produce typed modules with configuration
- Preserve private internal types without exposing them in public type declarations
- Compose modules with higher-order factory helpers and strongly typed combinators
- Add runtime guards and assertion functions at module boundaries for safety
- Apply memoization and caching patterns inside modules for performance
Prerequisites & Setup
You should be comfortable with TypeScript basics: interfaces, function types, generics, and type utility helpers like ReturnType and Omit. This guide uses TypeScript 4.x features; ensure your tsconfig targets a recent lib and includes "strict" mode for best results.
Recommended setup:
- Node.js and npm/yarn
- TypeScript >= 4.2 (ideally latest stable)
- A modern editor like VS Code with the TypeScript language service
Create a small project:
mkdir ts-revealing-modules && cd ts-revealing-modules npm init -y npm install -D typescript npx tsc --init
Enable "strict": true in tsconfig for the best type-safety feedback.
Main Tutorial Sections
1) Basic Revealing Module in TypeScript (a simple counter)
Start with a compact example to ground the pattern. We'll write a factory function that creates a counter with private state.
function createCounter() {
let value = 0 // private
function increment() {
value++
return value
}
function reset() {
value = 0
}
function getValue() {
return value
}
return { increment, reset, getValue }
}
const c = createCounter()
console.log(c.increment()) // 1To add types, declare the return shape explicitly. This keeps internal state private as far as consumers are concerned:
interface CounterAPI { increment: () => number; reset: () => void; getValue: () => number }
function createCounterTyped(): CounterAPI {
let value = 0
return {
increment() { value++; return value },
reset() { value = 0 },
getValue() { return value }
}
}This approach is explicit and clear: the public contract is an interface, and implementation details stay in the closure.
2) Using ReturnType and Type Inference for Better DX
Instead of duplicating an interface, use TypeScript inference to derive the public type:
function createTimer(prefix: string) {
let ticks = 0
return {
tick() { ticks++; return `${prefix}:${ticks}` },
read() { return ticks }
}
}
type TimerAPI = ReturnType<typeof createTimer>Using ReturnType keeps the function signature as the single source of truth. Consumers can reference TimerAPI for types without maintaining a separate interface.
3) Generics for Configurable Modules
Many modules accept configuration. Use generics to keep the types precise across the module.
function createStore<S>(initial: S) {
let state = initial
return {
get(): S { return state },
set(next: S) { state = next },
}
}
const numberStore = createStore(0)
const objStore = createStore({ a: 1 })
type Store<T> = ReturnType<typeof createStore<T>>Generics let the module work for many data shapes while retaining type safety.
4) Hiding Internal Helpers & Types
You may want internal helper functions or types that must not be part of the public API. Keep them in the closure scope and avoid exposing them on the returned object.
function createParser() {
type Token = { type: 'num' | 'op'; value: string }
function lex(src: string): Token[] { /* ... */ return [] }
function parse(tokens: Token[]) { /* ... */ }
return {
parseString(src: string) { const tokens = lex(src); return parse(tokens) }
}
}
// Token type is not visible outside createParserThis pattern provides real encapsulation: internal types and helpers are not exported.
5) Using Assertion Functions at Module Boundaries
When module inputs can be dynamic (for example an untyped JSON payload), use assertion functions to enforce invariants and maintain typed output. TypeScript 3.7+ supports assertion functions; they are especially useful at module boundaries.
import { assertIsConfig } from './guards' // imagine this uses assertion functions
function createConfiguredModule(config: unknown) {
assertIsConfig(config)
// now config is narrowed to the expected type
return { /* typed API */ }
}Using assertion functions keeps types safe and improves reliability — see our deeper guide on using assertion functions for patterns and pitfalls.
6) Combining Revealing Modules (Composition)
Modules are often composed to build larger features. Compose factory functions by passing one module into another or by merging APIs carefully.
function createLogger(prefix: string) {
return { log(msg: string) { console.log(prefix, msg) } }
}
function createService(logger: ReturnType<typeof createLogger>) {
return {
doWork() { logger.log('working') }
}
}
const logger = createLogger('svc')
const svc = createService(logger)For more advanced composition rules and function combinators, examine patterns from typing higher-order functions — the same type techniques apply to modules.
7) Memoization & Caching Inside Modules
Internal caching and memoization are common techniques to optimize modules. Implement them inside the closure and keep cache types private.
function createExpensiveCalculator() {
const cache = new Map<string, number>()
function expensive(key: string) {
if (cache.has(key)) return cache.get(key)!
const result = Math.random() // simulate
cache.set(key, result)
return result
}
return { expensive }
}For patterns, tradeoffs, and typing strategies related to caches and memoization, check out typing cache mechanisms and typing memoization functions.
8) Exposing Getters and Setters While Keeping Encapsulation
A module might want to expose a getter-like API that feels like a property. Use functions or property getters on the returned object with care.
function createPerson(name: string) {
let secretNote = 'private'
return {
get name() { return name },
setNote(note: string) { secretNote = note },
readNote() { return secretNote }
}
}
const p = createPerson('alice')
console.log(p.name)If you need to type real getters/setters in complex objects, our guide on typing getters and setters covers nuances and edge cases.
9) When to Choose Revealing Modules vs Classes or Singletons
Revealing modules are great for small utilities and when you want minimal runtime overhead. For richer OOP features, inheritance, or protected members, classes may be a better fit. If you need a single shared instance with module-level state, consider module singletons.
Example: a module singleton created with a top-level IIFE or a module file itself can act as a singleton. For patterns and typing considerations, read typing singleton patterns.
Consider the maintainability trade-offs: modules are simpler and more functional, while classes provide built-in privacy (with private fields) and inheritance semantics.
Advanced Techniques
Once you have the basics working, several advanced techniques help scale revealing modules safely:
- Typed opaque return types: create a public interface and export only that interface while keeping the factory function implementation unexported. This prevents consumers from relying on implementation details.
- Unique symbol keys for internal methods: define unique symbols in the module to hold methods you never intend to reveal publicly. You can type them internally and avoid exposing them on the public contract — see the article on typing symbols as object keys for advanced usage.
- Composition with strongly typed combinators: write higher-order factories that accept modules as arguments and return composed modules with combined typed APIs. Use intersection types with careful naming to avoid leaking internals — patterns from typing higher-order functions apply well here.
- Runtime guards for untrusted inputs: pair assertion functions with public factory entry points to ensure modules only operate on validated data. This is especially useful when a module receives raw JSON or operates in mixed-type environments — learn more in using assertion functions.
- In-memory caching with typed keys: internal caches should have private key types and value types; keep the Map or WeakMap hidden inside the module and expose typed accessor functions. For performance patterns and memory considerations, check typing cache mechanisms and typing memoization functions.
Best Practices & Common Pitfalls
Dos:
- Do explicitly type the module's public API — either with an interface or by exporting ReturnType
. This makes intent clear. - Do keep private state in the closure scope. Avoid returning objects that contain internal mutable state references directly.
- Do validate untrusted inputs at the factory boundary with assertion functions.
- Do document lifetime and side effects of modules (e.g., caches that never clear).
Don'ts:
- Don't rely on duck-typing to hide fields — prefer explicit public contracts so refactors are safer.
- Don't expose internal mutable objects directly; return copies or read-only views where appropriate.
- Don't assume TypeScript enforces runtime privacy. Types are erased; use runtime techniques if you need strong privacy guarantees.
Common troubleshooting:
- "Why is my private type visible?" — If you export the factory function, TypeScript might infer types that leak internal names. Export only the public interface or hide implementation behind an unexported factory and export a well-defined type alias.
- "My consumer keeps accessing internal fields" — Make the public API minimal and consider returning an object with functions rather than exposing the internal object directly.
For help modeling private state with classes as an alternative, see typing private and protected class members.
Real-World Applications
Revealing modules are ideal for small to medium features like:
- Utility libraries (e.g., date helpers, string processing) that need internal caches
- Client-side state stores or controllers where you want to avoid classes
- Encapsulated services that need to hold private handles like sockets or timers
- Feature-scoped singletons inside an application layer
For example, building a memoized fetch wrapper inside a module gives you an isolated cache and a small public API. This pattern pairs well with the memoization techniques described in typing memoization functions in TypeScript and with cache strategies in typing cache mechanisms.
Conclusion & Next Steps
The revealing module pattern is a flexible and lightweight way to produce encapsulated, composable APIs in TypeScript. By combining precise public interfaces, generics, and runtime guards, you can maintain type safety and performance without leaking implementation details. Next, try converting an existing utility or small service in your codebase into a typed revealing module and measure the API clarity improvements.
Suggested next reads: explore typing getters and setters for accessor patterns, and typing higher-order functions to power advanced composition.
Enhanced FAQ
Q: What exactly is the "revealing" aspect of a revealing module?
A: "Revealing" refers to explicitly returning the subset of functions and values that make up the public API. Internals remain in closure scope and are therefore hidden from consumers. The pattern emphasizes clarity: the returned object spells out the API surface.
Q: How do I keep internal types from leaking into exported types?
A: Avoid exporting the concrete factory function or its internal helper types. Export an explicit interface or a type alias derived from ReturnType of a non-exported factory. Alternatively, create an exported interface and implement the factory in the same module without exporting internal helper types.
Q: Are there runtime privacy guarantees with closures vs private class fields?
A: Closures provide runtime privacy because variables captured in the function scope are not accessible externally. Private class fields (using "#field") also provide runtime privacy. TypeScript's private modifier is only a compile-time check; the new private fields are runtime-enforced in JS.
Q: When should I prefer a class over a revealing module?
A: Use classes when you need instance inheritance, protected members, or when integrating with frameworks that expect class instances. Choose revealing modules for simpler utilities and when you want minimal runtime overhead.
Q: Can revealing modules be singletons?
A: Yes. A module file that exports a created instance or an IIFE that returns a single exposed object can act as a singleton. See typing singleton patterns for best practices and typing considerations.
Q: How should I type functions exposed by the module that accept callbacks?
A: Use explicit function types in the public interface and prefer generic callback types when you need flexibility. If you compose or wrap callbacks, patterns from typing higher-order functions help you preserve argument and return types.
Q: Is it safe to use Maps and WeakMaps as internal caches in a module?
A: Yes — Maps and WeakMaps are great choices for in-memory caches. Keep them private inside the closure to avoid accidental external mutation. For typed cache strategies and performance considerations, review typing cache mechanisms.
Q: How do assertion functions help modules?
A: Assertion functions validate runtime inputs and narrow types immediately at function entry points. Use them at factory boundaries or before expensive internal computations to ensure the module operates on valid, typed data. For patterns and usage, check using assertion functions.
Q: What's the recommended way to write unit tests for revealing modules?
A: Test the public API exclusively. Since internals are hidden, tests should interact with the returned object or exported interface. For behaviors that depend on internal state (like caches), verify observable side effects rather than internal variables. If needed, provide test-only hooks behind feature flags or use dependency injection to observe internals.
Q: Can I combine revealing modules with symbol-based private keys?
A: Yes. Defining unique symbols inside the module lets you attach internal helpers that won't clash with normal property names. Type them internally and avoid adding them to the public contract. For more on symbols as keys, see typing symbols as object keys.
Q: How do I migrate an existing class-based module to a revealing module?
A: Identify the public methods and properties the class exposes, move those into a factory return object, and convert instance fields to closure variables. If you used protected or inherited behavior, reconsider whether a revealing module is the right fit — classes may be preferable for complex inheritance.
Q: Where do I learn more about optimization patterns like memoization inside a revealing module?
A: Check the focused guides on caching and memoization: typing cache mechanisms and typing memoization functions in TypeScript. They present strategies to safely keep caches private and typed.
If you want, I can walk through converting a small real module from your codebase into a typed revealing module step-by-step. Provide a sample file and we can refactor it together.
