What You May Not Know About TypeScript (Part 4)
Explore the hidden depths of TypeScript in this blog series. Discover its lesser obvious details, expanding your understanding.
This is the fourth article (part 4) 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 TypeSscript, when target >= ES2022
or useDefineForClassFields
is true
in your tsconfig.json
, class fields are initialized after the parent class constructor completes, overwriting any value set by the parent class.
This can be a problem when you only want to re-declare a more accurate type for an inherited field:
To handle these cases, you can write declare
to indicate to TypeScript that there should be no runtime effect for this field declaration:
In TypeScript, you can control whether certain properties or methods are visible to code outside the class using the public
, protected
, and private
visibility modifiers.
The default visibility of class members is public
. A public
member can be accessed anywhere:
Because public
is already the default visibility modifier, you don't need to write it on a class member, but might choose to do so for style/readability reasons.
protected
members are only visible to subclasses of the class they’re declared in:
private
is like protected, but doesn't allow access to the member even from subclasses:
Static members can also use the same public
, protected
, and private
visibility modifiers:
Static members are also inherited:
In TypeScript, though derived classes need to follow their base class contracts, they may increase (but not decrease) the visibility of inherited members
In general, derived classes need to follow their base class contracts, but may expose a subtype of base class with more capabilities.
For example, making protected
members public
:
Note that Derived
was already able to read and write m
, so this doesn't meaningfully alter the "security" of this situation. The main thing to note here is that in the derived class, we need to be careful to repeat the protected
modifier if this exposure isn't intentional.
Because private
members aren't visible to derived classes, a derived class can't increase or decrease their visibility:
In TypeScript, it's illegal to access a protected
member through a base class reference.
This is because accessing x
in Derived2
should only be legal from Derived2
’s subclasses, and Derived1
isn't one of them. Moreover, if accessing x
through a Derived1
reference is illegal (which it certainly should be!), then accessing it through a base class reference should never improve the situation.
TypeScript allows cross-instance private
access.
Different OOP languages disagree about whether different instances of the same class may access each others' private members. While languages like Java, C#, C++, Swift, and PHP allow this, Ruby does not.
TypeScript does allow cross-instance private
access:
In TypeScript, like other aspects of its type system, private
and protected
are only enforced during type checking.
This means that JavaScript runtime constructs like in
or simple property lookup can still access a private
or protected
member:
private
also allows access using bracket notation during type checking. This makes private
-declared fields potentially easier to access for things like unit tests, with the drawback that these fields are soft private and don't strictly enforce privacy.
In TypeScript, unlike JavaScript, private
fields are soft private, whereas JavaScript private(#
) fields are hard private.
Unlike TypeScripts's private
, JavaScript’s private fields (#
) remain private after compilation and do not provide the previously mentioned escape hatches like bracket notation access, making them hard private.
The above code compiles to:
When compiling to ES2021 or less, TypeScript will use WeakMaps in place of #
:
If you need to protect values in your class from malicious actors, you should use mechanisms that offer hard runtime privacy, such as closures, WeakMaps, or private fields. Note that these added privacy checks during runtime could affect performance:
In TypeScript, unlike JavaScript, certain static
names that conflicts with Function
properties can’t be used.
It's generally not safe/possible to overwrite properties from the Function
prototype. Because classes are themselves functions that can be invoked with new
, certain static
names can't be used. Function properties like name
, length
, and call
aren't valid to define as static members:
In TypeScript, the static members of a generic class can never refer to the class's type parameters.
This code isn't legal, and it may not be obvious why:
Remember that types are always fully erased! At runtime, there's only one Box.defaultValue
property slot. This means that setting Box<string>.defaultValue
(if that were possible) would also change Box<number>.defaultValue
- not good.
In TypeScript, you can use this is Type
in the return position for methods in classes and interfaces.
When mixed with a type narrowing (e.g. if
statements) the type of the target object would be narrowed to the specified Type
. For example:
A common use-case for a this-based type guard is to allow for lazy validation of a particular field. For example, this case removes an undefined
from the value held inside box
when hasValue
has been verified to be true:
TypeScript offers a special syntax for turning a constructor parameter into a class property with the same name and value.
These are called parameter properties and are created by prefixing a constructor argument with one of the visibility modifiers public
, private
, protected
, or readonly
. The resulting field gets those modifier(s):
In TypeScript, classes, methods, and fields may be abstract
.
An abstract method or abstract field is one that hasn’t had an implementation provided. These members must exist inside an abstract class, which cannot be directly instantiated.
The role of abstract classes is to serve as a base class for subclasses that implement all the abstract members. When a class doesn't have any abstract members, it is said to be concrete.
We can't instantiate Base
with new
because it's abstract. Instead, we need to make a derived class and implement the abstract members:
Notice that if we forget to implement the base class's abstract members, we'll get an error:
In TypeScript, you can use abstract construct signatures to accept class constructor functions that produce an instance of a class that derives from some abstract class.
For example, you might want to write this code:
TypeScript is telling you that you're trying to instantiate an abstract class. After all, given the definition of greet
, it's perfectly legal to write this code, which would end up constructing an abstract class:
Instead, you want to write a function that accepts something with a construct signature:
Now TypeScript correctly tells you about which class constructor functions can be invoked - Derived
can because it's concrete, but Base
cannot.
In TypeScript, subtype relationships exist between classes considered the same when compared structurally, even if there is no explicit inheritance.
In most cases, classes in TypeScript are compared structurally, the same as other types. For example, these two classes can be used in place of each other because they're identical:
Similarly, subtype relationships between classes exist even if there's no explicit inheritance:
This sounds straightforward, but there are a few cases that seem stranger than others. Empty classes have no members. In a structural type system, a type with no members is generally a supertype of anything else. So if you write an empty class (don’t!), anything can be used in place of it:
TypeScript has a specific ES Module Syntax for working with types.
Types can be exported and imported using the same syntax as JavaScript values:
However, TypeScript has extended the import
syntax with two concepts for declaring an import of a type.
The first is import type
, which is an import statement which can only import types:
The second is an inline type import. TypeScript 4.5 also allows for individual imports to be prefixed with type
to indicate that the imported reference is a type:
Together these allow a non-TypeScript transpiler like Babel, swc, or esbuild to know what imports can be safely removed.
TypeScript has its module format called namespaces
which pre-dates the ES Modules standard.
This syntax has a lot of useful features for creating complex definition files and still sees active use in DefinitelyTyped. While not deprecated, the majority of the features in namespaces
exist in ES Modules and TypeScript recommends you use that to align with JavaScript’s direction. You can learn more about modules in the modules reference page and namespaces in the namespaces reference page.
TypeScript provides several global utility types to facilitate common type transformations.
If you already have an existing type, and what to transform it, you should look first into the utility types provided by TypeScript, as there might exist one that already does what you want instead of reinventing the wheel.
To keep this blog post at a readable length, I won't dive into them; you can learn them from the TypeScript docs: Utility types in TypeScript.
My advice on utility types is that you don't have to memorize them, know that they exist, and reach out for them when you want to transform an existing type; as you use them often they will become second nature.
In TypeScript, declaration merging for interfaces is only allowed when non-function members of the interfaces are unique.
The simplest and most common type of declaration merging is interface merging. At the most basic level, the merge mechanically joins the members of both declarations into a single interface with the same name:
Non-function members of the interfaces should be unique. If they are not unique, they must be of the same type. The compiler will issue an error if the interfaces both declare a non-function member of the same name but of different types:
In TypeScript, declaration merging for interfaces treats each function member of the same name as describing an overload of the same function.
Of note, too, is that in the case of interface A
merging with later interface A
, the second interface will have a higher precedence than the first.
That is, in the example:
The three interfaces will merge to create a single declaration like this:
Notice that the elements of each group maintain the same order, but the groups themselves are merged with later overload sets ordered first.
One exception to this rule is specialized signatures. If a signature has a parameter whose type is a single string literal type (e.g. not a union of string literals), then it will be bubbled toward the top of its merged overload list.
For instance, the following interfaces will merge:
The resulting merged declaration of Document
will be the following:
Conclusion
Remember, "hackers hack, crackers crack, and whiners whine. Be a hacker." Take care.