Almost four years ago, I was a new TypeScript user, amazed by the possibilities that this freshly learned JavaScript dialect opened up to me. But just like every TypeScript developer, I soon ran into some hard-to-debug problems.
In TypeScript land, those problems usually stem from the programmer's lack of understanding about the language itself.
I'd like to introduce you to one of these early problems I had, mostly because it is related to one of the (in my opinion) most underreported topics in the TypeScript tutorial world: the type scope. It's kind of obvious once you realize it exists, but for me it was a frequent source of confusion when I didn't know about it.
The Problem
My problem was actually very simple: I was building a library with a bunch of classes distributed over various folders.
For the library's public API, I wanted those classes to be exposed as a single nested object (e.g. the Console
class from Output/Console.ts
being available as API.Output.Console
).
So I defined some namespaces, imported the classes โ and then I struggled. I was just not able to re-export the classes from inside the namespaces. ๐คจ
First attempt, turned out to be invalid TypeScript:
import Console from './Output/Console'
export namespace Output {
export Console
// TS Error 1128: Declaration or statement expected.
}
Maybe I need to import it under a different name. Okay, second attempt:
import ConsoleAlias from './Output/Console'
export namespace Output {
export Console = ConsoleAlias
// TS Error 2304: Cannot find name 'Console'.
}
Third attempt โ maybe doing it the ES Modules way cuts it. (Spoiler: It didn't.)
import ConsoleAlias from './Output/Console'
export namespace Output {
export { ConsoleAlias as Console }
//TS Error 1194: Export declarations are not permitted in a namespace.
}
Fourth attempt using export const
. This actually compiled. ๐
import ConsoleAlias from './Output/Console'
export namespace Output {
export const Console = ConsoleAlias
}
But unfortunately, whenever I wanted to type hint something with that API
object, I got the following error:
import * as API from './API'
let console: API.Output.Console
// TS Error 2694: Namespace 'API.Output' has no exported member 'Console'.
...but I exported that member! Why is it not there!?
Hum. Maybe the export const
is the problem. I should sprinkle some magic TypeScript keywords over the problem and try export type
instead.
So without further ado: Fifth attempt. It compiles! ๐ฅณ
import ConsoleAlias from './Output/Console'
export namespace Output {
export type Console = ConsoleAlias
}
Okay, reality check: Does the type hint work? It does! ๐บ
...but that joy was short-lived as well. When I tried to create a new Console
instance, TypeScript errors were all over me again:
import * as API from './API'
const console = new API.Output.Console()
// TS Error 2708: Cannot use namespace 'API' as a value.
Oh come on.
Needless to say that I was pretty fed up with TypeScript at that point.
However, I did not want to believe that there was no solution to my problem, so I went to StackOverflow and, after a couple of days with no answer, I created an issue directly in TypeScript's GitHub repository.
The Solution
Ryan from the TypeScript team was kind enough to answer my question just within a couple of minutes. To me, the solution seemed pretty obvious and pretty obscure at the same time:
I applied that approach โ and it worked like a charm.
Why It Works
Back then, I just accepted that answer and used it in my code. It sounded kind of plausible to me โ both, const
and type
, worked in some way, so I just need to combine them to make both use cases working. But there was a sense of unease in it. Why could I export two things under the same name without producing a big fat compiler error?
It took me some more months (maybe even years) of TypeScript experience to fully understand why this works, but I think that insight might be valuable for others as well, so I'll share it here:
TypeScript has a secret scope.
TypeScript basically maintains a type scope which is completely independent of the variable scope of JavaScript. This means that you may declare a variable foo
and a type foo
in the same file. ๐คฏ They don't even need to be compatible:
const foo = 'bar'
type foo = number
// โ
This is absolutely fine for TypeScript
Now classes in TypeScript are a little bit special. What happens if you define a class Foo
is that TypeScript not only creates a variable Foo
(containing the class object itself) it also declares a type Foo
, representing an instance of the Foo
class.
class Foo {}
// We can use Foo as a type
let foo: Foo
// We can use Foo as a constructor (i.e. a value)
const bar = new Foo()
Similarly, when importing a name from another file (like we do with ConsoleAlias
in the second code sample), both โ the ConsoleAlias
class object and the ConsoleAlias
type โ are imported.
In other words, that single name โ the imported ConsoleAlias
โ holds both the class object and the type declared in Output/Console.ts
.
So if we re-export Console
from inside the Output
namespace by writing export const Console = ConsoleAlias
, only the class object is exported (because a const
only ever holds a value, not a type). Similarly, if we'd do export type Console = ConsoleAlias
, only the class type would be exported.
In a nutshell: Because of the independent scopes, it's valid to export a value as well as a type under the same name. And in some cases (like the one above), this is not only valid but necessary.
I hope this helped refine your mental model of TypeScript. ๐ค
Top comments (12)
TypeScript's "secret" scope was something I was also running into, but mainly because I was moving to it from Flow.
Flow isn't shy about there being "type land" and "code land", and never-the-twain-shall-meet. I really liked that separation, but there are upsides to how TS tackles it as well: just means we need to remember things like this post every so often haha
Honestly, I'm glad I'm not the only one who has hit that brick wall. I didn't learn TypeScript very linearly (i.e. I did not read the official TS manual start to finish) but I was quite surprised that until today, I've never encountered an article mentioning this distinction.
Even Ryan in his GitHub comment did not point me to any RTFM-style URL, so I suppose this concept is just implicitly accepted without anybody talking about it that much. ๐คทโโ๏ธ
That was about my experience too. The biggest thing that threw me for a loop and made me realise that there was this "mixing" of concerns going on?
import
-ing types as if they're actual code... but they're not code! Flow hasimport type { ...
which is much cleaner, but Typescript is so damned useful that it's silly to not use it.I don't even dislike the concept of mixing things up. It's pretty neat to be able to import a class which can be used as a constructor as well as a type.
But it obviously is less transparent and really carries the risk of newbies getting burned.
Nice! TypeScript 3.8 (in beta ATM) has
import type
and it would give a compiler error if you used a type-ish import (like a class) fromimport type
in a value positionInteresting, didn't know that. ๐
For anyone wanting to read more: TypeScript 3.8 beta announcement
This is surprising. I, being a beginner in TypeScript thought that types were implicitly tied with the classes I defined. I thought types worked similar to Java where one imports a class also imports the type implicitly. Thanks for pointing out the "secret" in typescript.
Your assumption is not wrong though! If a class has been declared in TypeScript, its value and its type do implicitly exist under the same name, therefore they're also imported together
But this is a rather special case for classes, most (all?) other entities do not create an implicit type alongside them.
Not a secret. They are called declaration spaces : basarat.gitbook.io/typescript/proj... ๐น
Of course not a secret. But as a title, it definitely sounds more appealing than "TypeScript's Lesser Known Parallel Universe". ๐
Thanks for the pointer to the official terminology though. ๐
This separation of values and types explains so many issues I've had over the years... especially with older type definition files and bundlers.
Thanks for the explanation!
You're welcome. ๐