loading...
Cover image for TypeScript's Secret Parallel Universe

TypeScript's Secret Parallel Universe

loilo profile image Florian Reuschel ใƒปUpdated on ใƒป5 min read

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.

Animation showing a set of virtual switches labelled with "Bug" plus some number. Whenever one of the switches is toggled, a seemingly random number of other switches is automatically toggled as well.

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.

The Visual Studio Code file tree showing three TypeScript files (Console.ts, File.ts and Stream.ts) in a folder named "Output"

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.

Video of a man throwing his computer into the dumpster

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:

Screenshot of Ryan Cavanaugh's answer to my question on GitHub, stating that I should be using both "export const" and "export type" 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. ๐Ÿค“

Discussion

pic
Editor guide
Collapse
girvo profile image
Josh Girvin

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

Collapse
loilo profile image
Florian Reuschel Author

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. ๐Ÿคทโ€โ™‚๏ธ

Collapse
girvo profile image
Josh Girvin

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 has import type { ... which is much cleaner, but Typescript is so damned useful that it's silly to not use it.

Thread Thread
loilo profile image
Florian Reuschel Author

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.

Thread Thread
orta profile image
Orta

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) from import type in a value position

Thread Thread
loilo profile image
Florian Reuschel Author

Interesting, didn't know that. ๐Ÿ‘

For anyone wanting to read more: TypeScript 3.8 beta announcement

Collapse
svijaykoushik profile image
Vijay Koushik, S. ๐Ÿ‘จ๐Ÿฝโ€๐Ÿ’ป

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.

Collapse
loilo profile image
Florian Reuschel Author

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.

Collapse
basarat profile image
Basarat Ali Syed

Not a secret. They are called declaration spaces : basarat.gitbook.io/typescript/proj... ๐ŸŒน

Collapse
loilo profile image
Florian Reuschel Author

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. ๐Ÿ‘

Collapse
seangwright profile image
Sean G. Wright

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!

Collapse
loilo profile image
Florian Reuschel Author

You're welcome. ๐Ÿ™‚