What You May Not Know About TypeScript (Part 2)
Explore the hidden depths of TypeScript in this blog series. Discover its lesser obvious details, expanding your understanding.
This is the second article (part 2) 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.
TypeScript checks that you passed the right number of arguments to a function, regardless of using type annotations on parameters or not.
In JavaScript, if you call a function with more arguments than there are parameters, the extra arguments are simply ignored. TypeScript behaves the same way. Functions with fewer parameters (of the same types) can always take the place of functions with more parameters:
When writing a function type for a callback, never write an optional parameter unless you intend to call the function without passing that argument.
In TypeScript, you can use the unknown
type to describe functions that accept any value without having any
values in your function body.
The unknown
type represents any value. This is similar to the any
type, but is safer because it’s not legal to do anything with an unknown
value:
Conversely, you can describe a function that returns a value of unknown
type:
In TypeScript, you can use the never
type to describe functions that never return a value.
Some functions never return a value:
The never
type represents values which are never observed. In a return type, this means that the function throws an exception or terminates execution of the program.
TypeScript will infer a function's return type based on its return
statements, making return type annotation optional.
Much like variable type annotations, you usually don't need a return type annotation because TypeScript will infer the function's return type based on its return statements.
Some codebases will explicitly specify a return type for documentation purposes, to prevent accidental changes, or just for personal preference.
TypeScript uses contextual typing to automatically provide types for a value depending on the context in which the value was used.
This is very obvious in anonymous functions, which are a little bit different from function declarations. When a function appears in a place where TypeScript can determine how it's going to be called, the parameters of that function are automatically given types.
Here's an example:
Even though the parameter s
didn't have a type annotation, TypeScript used the types of the forEach
function, along with the inferred type of the array, to determine the type s
will have. This process is called "contextual typing" because the context that the function occurred within informs what type it should have.
In TypeScript, unlike JavaScript, the inferred type of a function that doesn’t have any return statements, or doesn’t return any explicit value from those return statements is void
.
In JavaScript, a function that doesn't return any value will implicitly return the value undefined
; in TypeScript, it's inferred type is void
:
"But passing void
as an argument to typeof
returns undefined
", you may say. Yes, you're correct, but void
and undefined
are not the same thing in TypeScript. For example, contextual typing with a return type of void
does not force functions to not return something. Another way to say this is a contextual function type with a void
return type (type voidFunc = () => void
), when implemented, can return any other value, but it will be ignored.
Thus, the following implementations of the type () => void
are valid, and when the return value of one of these functions is assigned to another variable, it will retain the type of void
:
This behavior exists so that the following code is valid even though Array.prototype.push
returns a number and the Array.prototype.forEach
method expects a function with a return type of void
:
There is one other special case to be aware of when a literal function definition has a void
return type, that function must not return anything:
TypeScript requires you to check for undefined
when you read from an optional value before using it.
In JavaScript, if you access a property that doesn't exist, you'll get the value undefined
rather than a runtime error. Because of this, when you read from an optional property, you'll have to check for undefined
before using it.
In TypeScript, you can use the non-null assertion operator (postfix!
) to remove null
and undefined
from a type without doing any explicit checking.
Writing !
after any expression is effectively a type assertion that the value isn't null
or undefined
:
Just like other type assertions, this doesn't change the runtime behaviour of your code, so it's important to only use !
when you know that the value can't be null
or undefined
.
TypeScript will only allow an operation if it is valid for every member of a union in a union type.
A union type is a type formed from two or more other types, representing values that may be any one of those types. We refer to each of these types as the union's members.
TypeScript will only allow an operation if it is valid for every member of the union. For example, if you have the union string | number
, you can't use methods that are only available on string
:
The solution is to "narrow" the union with code, the same as you would in JavaScript without type annotations. Narrowing occurs when TypeScript can deduce a more specific type for a value based on the structure of the code.
For example, TypeScript knows that only a string value will have a typeof
value "string"
:
Sometimes you'll have a union where all the members have something in common. For example, both arrays and strings have a slice
method. If every member in a union has a property in common, you can use that property without narrowing:
TypeScript type aliases are only aliases - they do not create different/distinct "versions" of the same type.
You can use a type alias to give a name to any type at all. Note that aliases are only aliases - you cannot use type aliases to create different/distinct "versions" of the same type. When you use the alias, it's exactly as if you had written the aliased type. In other words, this code might look illegal, but is OK according to TypeScript because both types are aliases for the same type:
TypeScript's type alias cannot be re-opened to add new properties but an interface is always extendable.
Type aliases and interfaces are very similar, and in many cases, you can choose between them freely. Almost all features of an interface
are available in type
, the key distinction is that a type cannot be re-opened to add new properties after it is created (though it can be extended via intersections) vs an interface which is always extendable either through declaration merging or the extends
keyword.
TypeScript's interface
s, unlike its type
s, may only be used to declare the shapes of objects, not rename primitives.
Using type we can create custom names for existing primitives:
This isn't feasible with interfaces, as they can only extend other named object types, not primitives:
Which should you use? For the most part, you can choose based on personal preference, and TypeScript will tell you if it needs something to be the other kind of declaration. If you would like a heuristic, use interface
until you need to use features from type
.
In TypeScript, you can use type assertion to specify a more specific type of a value that TypeScript doesn't know about.
Sometimes you will have information about the type of a value that TypeScript can't know about.
For example, if you're using document.getElementById
, TypeScript only knows that this will return some kind of HTMLElement
, but you might know that your page will always have an HTMLCanvasElement
with a given ID.
In this situation, you can use a type assertion to specify a more specific type:
Like a type annotation, type assertions are removed by the compiler and won't affect the runtime behaviour of your code.
You can also use the angle-bracket syntax (except if the code is in a .tsx
file), which is equivalent:
Reminder: Because type assertions are removed at compile-time, there is no runtime checking associated with a type assertion. There won't be an exception or null
generated if the type assertion is wrong.
TypeScript only allows type assertions which convert to a more specific or less specific version of a type.
This rule prevents "impossible" coercions like:
Sometimes this rule can be too conservative and will disallow more complex coercions that might be valid. If this happens, you can use two assertions, first to any
(or unknown
), then to the desired type:
However, note that converting to a more specific or less specific version of a type won't change the typeof
value:
TypeScript creates types for literals.
In addition to the general types string
and number
, we can refer to specific strings and numbers in type positions.
One way to consider this is to consider how JavaScript comes with different ways to declare a variable. Both var
and let
allow changing what is held inside the variable, and const
does not. This is reflected in how TypeScript creates types for literals.
By themselves, literal types aren't very valuable, since it's not much use to have a variable that can only have one value! But by combining literals into unions, you can express a much more useful concept - for example, functions that only accept a certain set of known values:
There's one more kind of literal type: boolean literals. There are only two boolean literal types, and as you might guess, they are the types true
and false
. The type boolean
itself is actually just an alias for the union true | false
:
TypeScript only infers type literal as a type for a value if it cannot be changed.
When you initialize a variable with an object, TypeScript assumes that the properties of that object might change values later. For example, if you wrote code like this:
TypeScript doesn't assume the assignment of 1
to a field that previously had 0
is an error. Another way of saying this is that obj.counter
must have the type number
, not 0
, because types are used to determine both reading and writing behaviour.
The same applies to strings:
In the above example req.method
is inferred to be string
, not "GET"
. Because code can be evaluated between the creation of req
and the call of handleRequest
which could assign a new string like "GUESS"
to req.method
, TypeScript considers this code to have an error.
There are two ways to work around this.
- You can change the inference by adding a type assertion in either location:
Change 1 means "I intend for req.method
to always have the literal type"GET"
", preventing the possible assignment of "GUESS"
to that field after. Change 2 means "I know for other reasons that req.method
has the value "GET"
."
- You can use
as const
to convert the entire object to be type literals:
The as const
suffix acts like const
but for the type system, ensuring that all properties are assigned the literal type instead of a more general version like string
or number
.
In TypeScript, you can narrow types to more specific types than declared.
Much like how TypeScript analyzes runtime values using static types, it overlays type analysis on JavaScript's runtime control flow constructs like if
/else
, conditional ternaries, loops, truthiness checks, etc., which can all affect those types.
Say, within an if
check, TypeScript sees typeof padding === "number"
and understands that as a special form of code called a "type guard". TypeScript follows possible paths of execution that our programs can take to analyze the most specific possible type of a value at a given position. It looks at these special checks (called type guards) and assignments, and the process of refining types to more specific types than declared is called narrowing:
There are a couple of different constructs other than typeof
type guards that TypeScript understands for narrowing, to keep this article at a readable length I'll link to them since the TypeScript docs explain them very well:
- Truthiness narrowing
- Equality narrowing
- The
in
operator narrowing instanceof
narrowing- Assignments
- Control flow analysis
- Using type predicates
- Assertion funtions
- Discriminated Unions
TypeScript will use a never
type to represent a state that shouldn’t exist when narrowing.
When narrowing, you can reduce the options of a union to a point where you have removed all possibilities and have nothing left. In those cases, TypeScript will use a never
type to represent a state which shouldn’t exist, that is, never
appears when TypeScript determines there's nothing left in a union.
This is very useful in "exhaustiveness checking". The never
type is assignable to every type; however, no type is assignable to never
(except never
itself). This means you can use narrowing and rely on never
turning up to do exhaustive checking in a switch
statement.
For example, adding a default
to our getArea
function which tries to assign the shape to never
will not raise an error when every possible case has been handled:
Adding a new member to the Shape
union will cause a TypeScript error:
In TypeScript, you can write a construct signature by adding the new
keyword in front of a call signature, creating a constructor that creates a new object.
JavaScript functions can also be invoked with the new
operator. TypeScript refers to these as constructors because they usually create a new object. You can write a construct signature by adding the new
keyword in front of a call signature:
Some objects, like JavaScript's Date
object, can be called with or without new
. You can combine call and construct signatures in the same type arbitrarily:
In TypeScript, you can pass undefined
when a parameter in a function is optional.
When a parameter is optional (i.e., either it was marked with ?
or provided a default), callers can always pass undefined
, as this simulates a "missing" argument:
This is because once a parameter is marked optional, the parameter will have a union type, where the union members are the type specified, and undefined
. So, in our example above, x
will be annotated number | undefined
and y
will be string | undefined
.
In TypeScript, unlike JavaScript, you can specify a function that can be called in different ways by writing overload signatures.
Some JavaScript functions can be called in a variety of argument counts and types. For example, you might write a function to produce a Date
that takes either a timestamp (one argument) or a month/day/year specification (three arguments). To do this in TypeScript, write some number of function signatures (usually two or more), followed by the body of the function:
The function(s) without an implementation body are called the overload signatures. Then, the function with implementation is a compatible signature. Functions have an implementation signature, but this signature can't be called directly. This is because the signature used to write the function body can't be "seen" from the outside. So, in our example above, even though we wrote a function with two optional parameters after the overload signatures, it can't be called with two parameters!
When writing an overloaded function, you should always have two or more signatures above the function's implementation. The implementation signature must also be compatible with the overload signatures. For example, these functions have errors because the implementation signature doesn't correctly match the overloads:
In TypeScript, unlike JavaScript, you can have a parameter called this
.
The JavaScript specification states that you cannot have a parameter called this
, and so TypeScript uses that syntax space to let you declare the type for this
in a function body, where the resulting compiled code has this
removed from the function signature.
TypeScript will infer what the this
should be in a function via code flow analysis. For example, TypeScript understands that the function user.becomeAdmin
has a corresponding this
which is the outer object user
:
Consider the cases where you need more control over what object this
represents. This is where this feature comes in:
This pattern is common with callback-style APIs, where another object typically controls when your function is called. Note that you need to use function
and not arrow functions to get this behaviour:
Conclusion
Remember, "hackers hack, crackers crack, and whiners whine. Be a hacker." Take care.