TypeScript's Hidden Export Pattern: Dual Value/Type Exports with Aliasing
When generating TypeScript code, you often need to export both a runtime value and its corresponding type under the same name. This is common when wrapping external modules or creating type-safe abstractions. However, TypeScript's export system has subtle rules that can lead to "Duplicate identifier" errors if you're not careful.
Let's say you're generating code that wraps custom scalar implementations:
import * as CustomScalars from './scalars.js'
// Goal: Export both the value AND the type as "bigint"
// (ignoring that bigint is a reserved word for now)Your first instinct might be to re-export the value and create a matching type:
// ❌ FAILS: "Duplicate identifier 'bigint'"
export { bigint } from './scalars.js' // Re-export the value
type $bigint = typeof CustomScalars.bigint
export { $bigint as bigint } // Try to export type with same nameOr maybe try direct value export with type re-export:
// ❌ FAILS: "Duplicate identifier 'bigint'"
export const bigint = CustomScalars.bigint
type $bigint = typeof CustomScalars.bigint
export type { $bigint as bigint } // Conflict!Both approaches fail because TypeScript sees these as conflicting declarations in the same export namespace.
The key insight: When a value and type share the same name, they must be exported together in a single export statement.
TypeScript allows a const and type declaration to coexist with the same name in the local scope. When you export them together, TypeScript treats them as a unified value+type export:
// ✅ WORKS: Dual export with internal $ prefix
const $bigint = CustomScalars.bigint
type $bigint = typeof CustomScalars.bigint
export { $bigint as bigint }
// Exports BOTH value and type under the name "bigint"This works because:
const $bigintandtype $bigintcan coexist locally (different namespaces)export { $bigint as bigint }exports both the value binding AND type binding together- Consumers see a clean
bigintexport that works in both value and type positions
If your exported name isn't a TypeScript reserved keyword, you can use direct exports:
// ✅ WORKS: Direct exports for safe names
export const Date = CustomScalars.Date
export type Date = typeof DateTypeScript happily allows value and type exports with the same name when using direct export const/export type syntax.
When generating TypeScript modules with dual value/type exports:
Step 1: Check if the name is reserved (keywords like namespace, interface, type, etc., OR the global bigint type)
Step 2a: For reserved names, use the $ prefix pattern:
const $namespace = CustomScalars.namespace
type $namespace = typeof CustomScalars.namespace
export { $namespace as namespace }Step 2b: For safe names, use direct exports:
export const Date = CustomScalars.Date
export type Date = typeof DateThis pattern is particularly important when:
- Generating typed GraphQL schema wrappers (custom scalars)
- Creating type-safe configuration objects
- Wrapping external libraries with enhanced type information
- Building plugin systems where runtime and compile-time information must align
❌ Re-exporting value + separate type export with same name:
export { x } from './module'
type $x = typeof import('./module').x
export { $x as x } // Conflict!❌ Direct value export + type re-export with alias:
export const x = value
type $x = typeof value
export type { $x as x } // Conflict!✅ Local declarations with unified export:
const $x = value
type $x = typeof value
export { $x as x } // Both exported together!✅ Direct exports for safe names:
export const x = value
export type x = typeof value // No conflict in direct exports!TypeScript's export system treats value and type namespaces separately in most contexts, but when aliasing exports, you must export matching value/type pairs together in a single statement. By using local $-prefixed declarations and exporting them together with aliases, you can achieve clean exported names even when dealing with reserved keywords.
This pattern has been battle-tested in the Graffle GraphQL client generator, where custom scalars need both runtime codec implementations and compile-time type information exported under their GraphQL schema names.