What You May Not Know About TypeScript (Part 5)
Explore the hidden depths of TypeScript in this blog series. Discover its lesser obvious details, expanding your understanding.
This is the fifth article (part 5) in my series about "What You May Not Know About TypeScript." You might want to start reading from part 1 to get an introduction to what led me to write this. With that said, let's get started.
In TypeScript, declaration merging for namespaces have non-exported members only visible in the original (un-merged) namespace.
This means that after merging, merged members that came from other declarations cannot see non-exported members.
We can see this more clearly in this example:
Because haveMuscles
is not exported, only the animalsHaveMuscles
function that shares the same un-merged namespace can see the symbol. The doAnimalsHaveMuscles
function, even though it's part of the merged Animal
namespace can not see this un-exported member.
In TypeScript, namespaces are flexible enough to merge with other declarations.
To do so, the namespace declaration must follow the declaration it will merge with. The resulting declaration has properties of both declaration types. TypeScript uses this capability to model some patterns in JavaScript and other programming languages.
Merging namespaces with classes gives the user a way of describing inner classes:
The visibility rules for merged members are the same as when merging namespaces, so we must export the AlbumLabel
class for the merged class to see it. The result is a class managed inside of another class. You can also use namespaces to add more static members to an existing class.
In addition to the pattern of inner classes, you may also be familiar with the JavaScript practice of creating a function and then extending the function further by adding properties to the function. TypeScript uses declaration merging to build up definitions like this in a type-safe way.
Similarly, namespaces can be used to extend enums with static members:
In TypeScript, classes can not merge with other classes or with variables.
For information on mimicking class merging, see the Mixins in TypeScript.
In TypeScript, unlike JavaScript, you can use module augmentation to merge two modules.
Although JavaScript modules do not support merging, you can patch existing objects by importing and updating them.
Let's look at a toy Observable
example:
This works in TypeScript too, but the compiler doesn't know about Observable.prototype.map
. You can use module augmentation to tell the compiler about it:
The module name is resolved the same way as module specifiers in import
/export
. Then the declarations in an augmentation are merged as if they were declared in the same file as the original.
However, there are two limitations to keep in mind:
- You can't declare new top-level declarations in the augmentation — just patches to existing declarations.
- Default exports also cannot be augmented, only named exports (since you need to augment an export by its exported name, and the default is a reserved word - see #14080 for details).
In TypeScript, you can add declarations to the global scope from inside a module.
Global augmentations have the same behaviour and limits as module augmentations.
In TypeScript, numeric enums can be mixed in computed and constant members.
Each enum member has a value associated with it which can be either constant or computed. An enum member is considered constant if:
It is the first member in the enum and it has no initializer, in which case it's assigned the value
0
:It does not have an initializer and the preceding enum member was a numeric constant. In this case, the value of the current enum member will be the value of the preceding enum member plus one.
The enum member is initialized with a constant enum expression. A constant enum expression is a subset of TypeScript expressions that can be fully evaluated at compile time. An expression is a constant enum expression if it is:
- a literal enum expression (basically a string literal or a numeric literal)
- a reference to a previously defined constant enum member (which can originate from a different enum)
- a parenthesized constant enum expression
- one of the
+
,-
,~
unary operators applied to constant enum expression +
,-
,*
,/
,%
,<<
,>>
,>>>
,&
,|
,^
binary operators with constant enum expressions as operands
It is a compile time error for constant enum expressions to be evaluated to NaN
or Infinity
.
In all other cases, enum member is considered computed.
In TypeScript, enums without initializers either need to be first or have to come after numeric enums initialized with numeric constants or other constant enum members.
In other words, the following isn't allowed:
In TypeScript, enums can be mixed with string and numeric members.
Technically enums can be mixed with string and numeric members - they are called heterogenous enums, but it's not clear why you would ever want to do so:
Unless you're trying to take advantage of JavaScript's runtime behaviour in a clever way, it's advised that you don't do this.
In TypeScript, when all members in an enum have literal enum values, some special semantics come into play.
There is a special subset of constant enum members that aren't calculated: literal enum members. A literal enum member is a constant enum member with no initialized value, or with values that are initialized to
- any string literal (e.g.
"foo"
,"bar"
,"baz"
) - any numeric literal (e.g.
1
,100
) - a unary minus applied to any numeric literal (e.g.
-1
,-100
)
When all members in an enum have literal enum values, some special semantics come into play.
The first is that enum members also become types as well! For example, we can say that certain members can only have the value of an enum member:
The other change is that enum types themselves effectively become a union of each enum member. With union enums, the type system is able to leverage the fact that it knows the exact set of values that exist in the enum itself. Because of that, TypeScript can catch bugs where we might be comparing values incorrectly. For example:
In that example, we first checked whether x
was not E.Foo
. If that check succeeds, then our ||
will short-circuit, and the body of the if
will run. However, if the check didn't succeed, then x
can only be E.Foo
, so it doesn't make sense to see whether it's not equal to E.Bar
.
In TypeScript, enums are real objects that exist at runtime.
For example, the following enum:
can actually be passed around to functions:
In TypeScript, use keyof typeof
to get a type that represents all enum keys as strings
Even though enums are real objects that exist at runtime, the keyof
keyword works differently than you might expect for typical objects. Instead, use keyof typeof
to get a type representing all enum keys as strings:
In TypeScript, numeric enums members also get a reverse mapping from enum values to enum names.
In addition to creating an object with property names for members, numeric enums members also get a reverse mapping from enum values to enum names. For example, in this example:
TypeScript compiles this down to the following JavaScript:
In this generated code, an enum is compiled into an object that stores both forward (name
-> value
) and reverse (value
-> name
) mappings. References to other enum members are always emitted as property accesses and never inlined.
Keep in mind that string enum members do not get a reverse mapping generated at all.
In TypeScript, you can use ambient enums to describe the shape of already existing enum types.
One important difference between ambient and non-ambient enums is that, in regular enums, members that don't have an initializer will be considered constant if their preceding enum member is considered constant. By contrast, an ambient (and non-const) enum member that does not have an initializer is always considered computed.
In TypeScript, it's possible to use const
enums.
In most cases, enums are a perfectly valid solution. However sometimes requirements are tighter. To avoid paying the cost of extra generated code and additional indirection when accessing enum values, it's possible to use const
enums. Const enums are defined using the const
modifier on our enums:
Const enums can only use constant enum expressions and unlike regular enums they are completely removed during compilation. Const enum members are inlined at use sites. This is possible since const enums cannot have computed members.
in generated code will become
Inlining enum values is straightforward at first, but comes with subtle implications. These pitfalls pertain to ambient const enums only (basically const enums in .d.ts
files) and sharing them between projects, but if you are publishing or consuming .d.ts
files, these pitfalls likely apply to you, because tsc --declaration
transforms .ts
files into .d.ts
files. If this affects you, then do read about the const
enum pitfalls from TypeScript's documentation.
In modern TypeScript, you may not need an enum when an object with as const
could suffice.
The biggest argument in favour of this format over TypeScript's enum
is that it keeps your codebase aligned with the state of JavaScript, and when/if enums are added to JavaScript then you can move to the additional syntax.
In TypeScript, Iterable
is a type we can use if we want to take in types which are iterable.
An object is deemed iterable if it has an implementation for the Symbol.iterator
property. Some built-in types like Array
, Map
, Set
, String
, Int32Array
, Uint32Array
, etc. have their Symbol.iterator
property already implemented. Symbol.iterator
function on an object is responsible for returning the list of values to iterate on.
Here is an example:
for..of
loops over an iterable object, invoking the Symbol.iterator
property on the object. Here is a simple for..of
loop on an array:
Both for..of
and for..in
statements iterate over lists; the values iterated on are different though, for..in
returns a list of keys on the object being iterated, whereas for..of
returns a list of values of the numeric properties of the object being iterated:
Another distinction is that for..in
operates on any object; it serves as a way to inspect properties on this object. for..of
on the other hand, is mainly interested in values of iterable objects. Built-in objects like Map
and Set
implement Symbol.iterator
property allowing access to stored values:
In TypeScript, when targeting an ES5 or ES3-compliant engine, iterators are only allowed on values of Array
type.
It is an error to use for..of
loops on non-Array values, even if these non-Array values implement the Symbol.iterator
property.
The compiler will generate a simple for
loop for a for..of
loop, for instance:
will be generated as:
When targeting an ECMAScript 2015-compliant engine, the compiler will generate for..of
loops to target the built-in iterator implementation in the engine.
TypeScript provides a special type unique symbol
to enable treating symbols as unique literals.
unique symbol
is a subtype of symbol
, and is produced only from calling Symbol()
or Symbol.for()
, or from explicit type annotations. This type is only allowed on const
declarations and readonly static
properties, and to reference a specific unique symbol, you'll have to use the typeof
operator. Each reference to a unique symbol implies a unique identity that's tied to a given declaration:
Because each unique symbol
has a completely separate identity, no two unique symbol
types are assignable or comparable to each other:
In TypeScript, you can use triple-slash directives at the top of your containing file to specify compiler directives.
Triple-slash directives are single-line comments containing a single XML tag. The contents of the comment are used as compiler directives.
Triple-slash directives are only valid at the top of their containing file. A triple-slash directive can only be preceded by single or multi-line comments, including other triple-slash directives. If they are encountered following a statement or a declaration they are treated as regular single-line comments and hold no special meaning. You can learn more about triple-slash directives from the TypeScript docs.
Conclusion
Remember, "hackers hack, crackers crack, and whiners whine. Be a hacker." Take care.