Introduction
Declaration merging is a concept in TypeScript which means the TS compiler will merge two or more separate declarations – with the same name – into one single declaration.
Ambient namespace declaration
A TypeScript namespace
allows you to access its exported values or types using dot-notation. While it's not recommended to use namespace
in today's code (hello, not standard EcmaScript), it's still recommended and useful for describing and organizing types.
An ambient namespace declaration is a fully erasable (at compile time) namespace declaration. In other words, it doesn't emit any code.
How is this useful?
An ambient namespace declaration can be merged with type aliases, interfaces, classes, and even functions. This allows you to access namespace's exported types using dot notation which is useful for organizing code.
How to do it?
Append the declare
keyword in front of the namespace
keyword to make it an ambient declaration. Then use the same name of the entity (interface / type / class / ...) – that you want to merge with – as the namespace name. Look at the following examples:
Organizing sub types and interfaces
Instead of exporting User
and UserDetails
interfaces, we can just define a single User
interface and use ambient namespace declaration to export sub types:
// example.ts
interface User {
FirstName: string
LastName: string
Details: User.Details
}
declare namespace User {
interface Details {
Address: string
}
}
const details: User.Details = {
Address: "Somewhere over the rainbow"
}
const user: User = {
FirstName: "Bob",
LastName: "Alice",
Details: details,
}
Note that because we're using declare
keyword, there is no need to export
the Details
interface (auto-exported).
Exposing interface property types
Sometimes it's useful to expose interface's property types. For instance, branded types:
// example.ts
interface User {
FirstName: User.FirstName
LastName: User.LastName
}
declare namespace User {
type FirstName = string & { __brand: "User:FirstName" }
type LastName = string & { __brand: "User:LastName" }
}
const firstName = <User.FirstName>"Bob"
const lastName = <User.LastName>"Alice"
const user: User = {
FirstName: firstName,
LastName: lastName,
}
In .tsx files: use
"Bob" as User.FirstName
and"Alice" as User.LastName
Exposing an interface's default type params
It's currently not possible in TypeScript to have partial type params, you either need to provide none or all of them. However, here is a way you can make it easier to access the default type params of an interface you have declared:
// example.ts
interface Foo<
T = Foo.T,
V = Foo.V,
S = Foo.S<T>,
> {
/** property types here */
}
declare namespace Foo {
type T = string
type V = string
type S<T> = { t: T, something: string }
}
const foo: Foo<
Foo.T,
Foo.V,
Foo.S<Foo.T>
> = {}
Scoped types in object literals
Sometimes we define object literals which may contain some methods. To prevent polluting the module scope with types – because naming things is hard, even more if there is already an existing type with the same name. e.g: how common is Callback
– we could use ambient namespaces:
// example.ts
const foo = <const>{
trigger(cb: foo.Callback): void { }
}
declare namespace foo {
interface Callback {
(value: string): void
}
}
const myCallback: foo.Callback = (value: string): void => {
return void console.log(value)
}
foo.trigger(myCallback)
This also applies to classes, enums, and functions.
Conclusion
- namespaces allow accessing its exported types and values using dot-notation
- ambient namespaces are fully erasable types (do not emit code)
- namespaces and ambient namespaces can be merged with other declarations in the same scope such as type aliases, interfaces, object literals, classes, enums, functions, and even other namespaces
- ambient namespaces are useful for organizing or scoping types
Top comments (0)