What You May Not Know About TypeScript (Part 3)
Explore the hidden depths of TypeScript in this blog series. Discover its lesser obvious details, expanding your understanding.
This is the third article (part 3) 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, a property in an object type can be marked as readonly
, which makes re-writing to it during type-checking an error.
Properties can also be marked as readonly
for TypeScript. While it won't change any behaviour at runtime, a property marked as readonly
can’t be written to during type-checking:
Using the readonly
modifier doesn't necessarily imply that a value is immutable or that its internal contents can't be changed. It just means the property itself can't be re-written to:
In TypeScript, readonly
properties in an object type can change via aliasing.
It's important to manage expectations of what readonly
implies. It's useful to signal intent during development time for TypeScript on how an object should be used. TypeScript doesn't factor in whether properties on two types are readonly
when checking whether those types are compatible, so readonly
properties can also change via aliasing:
In TypeScript, you can use an index signature to describe the types of possible values.
Sometimes you don't know all the names of a type's properties ahead of time, but the shape of the values you do know. In those cases, you can use an index signature to describe the types of possible values. Only some types are allowed for index signature properties: string
, number
, symbol
, template string patterns, and union types consisting only of these:
Above, we have a StringArray
interface which has an index signature. This index signature states that when a StringArray
is indexed with a number
, it will return a string
.
You can make index signatures readonly
to prevent assignment to their indices:
You can’t set myArray[2]
because the index signature is readonly
.
In TypeScript, string index signatures enforce that all properties match their return type.
While string index signatures are a powerful way to describe the "dictionary" pattern, they also enforce that all properties match their return type. This is because a string index declares that obj.property
is also available as obj["property"]
. In the following example, name
's type does not match the string index's type, and the type checker gives an error:
However, properties of different types are acceptable if the index signature is a union of the property types:
In TypeScript, if an object literal has any properties that the "target type" doesn't have, you'll get an error.
Where and how an object is assigned a type can make a difference in the type system. One of the key examples of this is in excess property checking, which validates the object more thoroughly when it is created and assigned to an object type during creation.
Consider the code below:
Notice the given argument to createSquare
is spelled colour
instead of color
. In plain JavaScript, this sort of thing fails silently. You could argue that this program is correctly typed, since the width
properties are compatible, there's no color
property present, and the extra colour
property is insignificant.
However, TypeScript takes the stance that there's probably a bug in this code. Object literals get special treatment and undergo excess property checking when assigning them to other variables, or passing them as arguments. If an object literal has any properties that the "target type" doesn’t have, you'll get an error, as shown above.
Getting around these checks is simple. The easiest method is to use a type assertion:
However, a better approach might be to add a string index signature if you're sure that the object can have some extra properties that are used in some special way. If SquareConfig
can have color
and width
properties with the above types, but could also have any number of other properties, then we could define it like so:
Here we're saying that SquareConfig
can have any number of properties, and as long as they aren't color
or width
, their types don't matter.
One final way to get around these checks, which might be a bit surprising, is to assign the object to another variable. Since assigning squareOptions
won't undergo excess property checks, the compiler won't give you an error:
The above workaround will work as long as you have a common property between squareOptions
and SquareConfig
. In this example, it was the property width
. It will however, fail if the variable does not have any common object property.
For example:
Keep in mind that for simple code like the above, you probably shouldn't be trying to "get around" these checks. For more complex object literals that have methods and hold state, you might need to keep these techniques in mind, but a majority of excess property errors are actually bugs.
That means if you're running into excess property checking problems for something like option bags, you might need to revise some of your type declarations. In this instance, if it's okay to pass an object with both a color
or colour
property to createSquare
, you should fix up the definition of SquareConfig
to reflect that.
TypeScript provides a ReadonlyArray
special type that describes arrays that shouldn’t be changed.
Much like the readonly
modifier for properties, it's mainly a tool we can use for intent. When we see a function that returns ReadonlyArray
s, it tells us we're not meant to change the contents. When we see a function that consumes ReadonlyArray
s, it tells us that we can pass any array into that function without worrying that it will change its contents.
Unlike Array
, there isn't a ReadonlyArray
constructor that we can use.
Instead, we can assign regular Array
s to ReadonlyArray
s.
Just as TypeScript provides a shorthand syntax for Array<Type>
with Type[]
, it also provides a shorthand syntax for ReadonlyArray<Type>
with readonly Type[]
.
One last thing to note is that, unlike the readonly
property modifier, assignability isn't bidirectional between regular Array
s and ReadonlyArray
s.
TypeScript provides a readonly
tuple type
Tuple types have readonly
variants, and can be specified by sticking a readonly
modifier in front of them, just like with array shorthand syntax.
As you might expect, writing to any property of a readonly
tuple isn't allowed in TypeScript.
Tuples tend to be created and left un-modified in most code, so annotating types as readonly
tuples when possible is a good default. This is also important given that array literal with const
assertions will be inferred withreadonly
tuple types.
Here, distanceFromOrigin
never modifies its elements but expects a mutable tuple. Since point
's type was inferred as readonly [3, 4]
, it won't be compatible with [number, number]
since that type can't guarantee point
's elements won't be mutated.
TypeScript's type system allows expressing types in terms of other types.
This is very powerful. We have a wide variety of type operators available to use. It's also possible to express types in terms of values that we already have.
By combining various type operators, we can express complex operations and values in a succinct, maintainable way. Below are the ways to express a new type in terms of an existing type or value and to keep this blog post at a readable length I'll just link to them since the TypeScript docs explains them very well:
- Generics - Types which take parameters
- Keyof Type Operator - Using the
keyof
operator to create new types - Typeof Type Operator - Using the
typeof
operator to create new types - Indexed Access Types - Using
Type['a']
syntax to access a subset of a type - Conditional Types - Types which act like if statements in the type system
- Mapped Types - Creating types by mapping each property in an existing type
- Template Literal Types - Mapped types which change properties via template literal strings
TypeScript, unlike JavaScript, does not analyze methods you invoke from a class constructor to detect initializations, hence fields needs to be initialized in the constructor itself.
Normally, fields in a class can have initializers, and these will run automatically when the class is instantiated; and just like with const
, let
, and var
, the initializer of a class property will be used to infer its type:
However, the strictPropertyInitialization
setting can be turned on to make TypeScript check for class properties that are declared but not set in the constructor. When this is done, for a class field declared but not initialized, TypeScript will issue an error, requesting it is initialized in the class constructor:
However, note that this initialization must be done explicitly in the constructor, and not from a method, as TypeScript doesn't analyze methods you invoke from a class constructor to detect initializations. This is because a derived class might override those methods and fail to initialize the members:
If you intend to definitely initialize a field through means other than the constructor (for example, maybe an external library is filling in part of your class for you), you can use the definite assignment assertion operator, !
.
In TypeScript, class fields may be prefixed with the readonly
modifier. This prevents assignments to the field outside of the constructor.
TypeScript treats class constructors as very similar to functions: you can add parameters with type annotations, default values, and overloads.
There are just a few differences between class constructor signatures and function signatures:
- Constructors can't have type parameters - these belong on the outer class declaration
- Constructors can't have return type annotations - the class instance type is always what's returned
In TypeScript, methods can use all the same type annotations as functions and constructors.
A function property on a class is called a method. Other than the standard type annotations, TypeScript doesn’t add anything else new to methods:
Note that inside a method body, it is still mandatory to access fields and other methods via this.
. An unqualified name in a method body will always refer to something in the enclosing scope:
TypeScript has some special inference rules for accessors i.e. getters and setters.
Classes can also have accessors:
TypeScript has some special inference rules for accessors:
- If
get
exists but noset
, the property is automaticallyreadonly
- If the type of the setter parameter is not specified, it is inferred from the return type of the getter
- Getters and setters must have the same Member Visibility
Note that a field-backed get
/set
pair with no extra logic is rarely useful in JavaScript. It's fine to expose public fields if you don’t need to add additional logic during the get/set operations.
Since TypeScript 4.3, it is possible to have accessors with different types for getting and setting:
In TypeScript, classes can declare index signatures; these work the same as index signatures for other object types.
Because the index signature type needs to capture the types of methods, it's not easy to use these types. Generally, it's better to store indexed data in another place, instead of on the class instance itself.
In TypeScript, you can use an implements
clause to check that a class satisfies a particular interface
.
An error will be issued if a class fails to implement an interface
correctly:
Classes may also implement multiple interfaces, e.g. class C implements A, B {
.
In TypeScript, an implements
clause is only a check that the class can be treated as the interface
type, it doesn’t change the class's type or its methods.
A common source of error is to assume that an implements
clause will change the class type - it doesn't!
In the above example, we perhaps expected that s
's type would be influenced by the name: string
parameter of check
; It didn't. implements
clauses don't change how the class body is checked or its type inferred.
In TypeScript, implementing an interface with an optional property doesn't create that property.
TypeScript, unlike JavaScript, enforces that a derived class is always a subtype of its base class.
For example, here's a legal way to override a method:
It's important that a derived class follow its base class contract. Remember that it's very common (and always legal!) to refer to a derived class instance through a base class reference:
What if Derived
didn't follow Base's contract?
If we compiled this code despite the error, this sample would then crash:
Conclusion
Remember, "hackers hack, crackers crack, and whiners whine. Be a hacker." Take care.