Typescript Advanced Concepts
Type Modifiers
any (Top type)
In type theory, a top type represents the type that is compatible with all other types. It is also known as the universal supertype or the any type. In other words, any value can be assigned to a variable of the top type. It is used to define a variable or parameter that can hold any type of value.
In TypeScript, the top type is represented by the any
type. The any
type can
hold any value, and it disables the type checking for the variable. It is often
used when the type of a variable is unknown or when interfacing with JavaScript
code that does not have types.
unknown
In contrast to any
, unknown
is a type-safe counterpart of any
that is
designed to represent values that are not known at the time of compilation or
that can come from various sources. Unlike any
, unknown
is a stricter type
that cannot be assigned to any other type without first being checked or cast to
a more specific type.
It is similar to the any type in that it allows any value to be assigned to it, but it provides additional type safety by not allowing the value to be used until it has been narrowed down to a more specific type.
- TypeScript does not allow directly accessing properties of
unknown
typed values. unknown
is not assignable to types that are not a top type (any
orunknown
).
The only way TypeScript will allow code to access members on a name of type
unknown
is if the value’s type is narrowed, such as using instanceof
or
typeof
, or with a type assertion.
never (Bottom type)
In type theory, a bottom type represents the type that is incompatible with all other types. It is also known as the universal subtype or the never type. In other words, no value can be assigned to a variable of the bottom type. It is used to define a function or expression that does not return a value or has an unreachable end point.
In TypeScript, the bottom type is represented by the never
type. The never
type
is used to indicate that a function or expression will never return a value, or
that it will always throw an error or enter an infinite loop. It is often used
in type annotations for functions that throw exceptions or functions that have
an unreachable end point.
Type Predicates
We can narrow types using JavaScript constructs such as instanceof
, typeof
and in
. That’s all fine and good for directly using that limited set of
checks, but it gets lost if you wrap the logic with a function.
Notice that it would work if we inlined typeof value === "number" || typeof value === "string";
.
It got lost because we wrap the logic with a function.
TypeScript has a special syntax for functions that return a boolean meant to
indicate whether an argument is a particular type. This is referred to as a type
predicate, also sometimes called a “user-defined type guard". You the developer
are creating your own type guard like instanceof
or typeof
.
Type predicates are commonly used to indicate whether an argument passed in as a parameter is a more specific type than the parameter’s.
You can think of a type predicate as returning not just a boolean, but also an indication that the argument was that more specific type.
Type predicates are often used to check whether an object already known to be an instance of one interface is an instance of a more specific interface.
A bad usage example of type predicates.
Type predicates that do more than verify the type of a property or value are easy to misuse. Avoid them when possible. Simpler type predicates are sufficient for most cases.
Type Operators
Type operators in TypeScript are used to manipulate types and create new types based on existing types. Type operators are needed in TypeScript to allow for more advanced type manipulation. They provide a way to transform or extract types based on certain conditions or operations at compile-time.
keyof
The keyof
operator is a type operator in TypeScript that is used to obtain the
union type of all the property names of a given type.
In other words, if you have a type T
with properties prop1
, prop2
, and prop3
,
then keyof T
will give you the union type "prop1" | "prop2" | "prop3"
.
The keyof operator is useful because it allows us to create generic code that works with a variety of object types, without knowing the specific keys in advance. Instead of hardcoding specific property names in our code, we can use keyof to reference the keys dynamically. This makes our code more flexible, maintainable, and less error-prone.
In this example, TypeScript gives an error because we are attempting to access
a property of the ratings object using a string value that may not be a valid
property name. Since TypeScript is unable to determine the type of the value
returned by ratings[key]
,
Another option would be to use a type union of literals for the allowed keys. That would be more accurate in properly restricting to only the keys that exist on the container value:
However, what if the interface has dozens or more members? You would have to type out each of those members’ keys into the union type and keep them up-to-date.
typeof
In TypeScript, the typeof operator is a type operator that returns the type of a given value or variable. It is used to obtain the static type of a value or variable at compile-time. It is a type-level operator that operates on values at compile-time rather than at runtime like the typeof operator in JavaScript.
Although the typeof
type operator visually looks like the runtime typeof
operator used to return a string description of a value’s type, the two are
different. They only coincidentally use the same word. Remember: the JavaScript
operator is a runtime operator that returns the string name of a type. The
TypeScript version, because it’s a type operator, can only be used in types and
won’t appear in compiled code.
keyof typeof
typeof
retrieves the type of a value, and keyof
retrieves the allowed keys on a
type.
TypeScript allows the two keywords to be chained together. Putting them together
keyof typeof
type operator is used to get a union type of all the keys in a
given type.
By combining keyof
and typeof
, we get to save ourselves the pain of writing
out and having to update—types representing the allowed keys on objects that
don’ t have an explicit interface type.
Const Assertions
In TypeScript, const assertions are a way to define a constant variable with a literal value that cannot be changed or narrowed by TypeScript's type system.
Const assertions can generally be used to indicate that any value—array, primitive, value, you name it—should be treated as the constant, immutable version of itself. Specifically, as const applies the following three rules to whatever type it receives:
- Arrays are treated as readonly tuples, not mutable arrays.
- Literals are treated as literals, not their general primitive equivalents.
- Properties on objects are considered readonly.
- Read-Only tuples:
- Literals to primitives
It can be useful for the type system to understand a literal value to be that specific literal, rather than widening it to its general primitive.
It may also be useful to have specific fields on a value be more specific literals.
- Read-Only Objects
Object literals such as those used as the initial value of a variable generally widen the types of properties the same way the initial values of let variables widen. String values such as 'apple' become primitives such as string, arrays are typed as arrays instead of tuples, and so on. This can be inconvenient when some or all of those values are meant to later be used in a place that requires their specific literal type.
Asserting a value literal with as const, however, switches the inferred type to be as specific as possible. All member properties become readonly, literals are considered their own literal type instead of their general primitive type, arrays become read-only tuples, and so on. In other words, applying a const assertion to a value literal makes that value literal immutable and recursively applies the same const assertion logic to all its members.
Generics
In TypeScript, generics provide a way to create reusable components that can work with a variety of types, rather than being tied to a single type. They allow you to write code that is more generic and flexible, and can be used across a wide range of use cases.
A generic type is a type that takes one or more type parameters. These type parameters act as placeholders for types that will be supplied later, when the generic type is used.
A type parameter is just a placeholder for a type that can be used in a function
or class definition. You can think of it as a variable that represents a type.
The syntax for declaring a type parameter is to put the name of the parameter in
angle brackets <>
immediately before the function or class name, like this:
Just like regular parameters allow you to pass values to a function, type parameters allow you to pass types to a generic function. And just like regular parameters can have default values, type parameters can also have default types that are used when the type argument is not specified.
Generic Functions
In this example, identity
is a function that takes a type parameter T
. This
means that the type of the argument and the return value will be the same as the
type of the value that is passed in.
The following arrow function is functionally the same as the previous declaration:
The syntax for generic arrow functions has some restrictions in .tsx files, as it conflicts with JSX syntax.
Explicit Generic Call Types
When calling a generic function, TypeScript will usually try to infer the type
arguments based on the type of the argument passed to the function. For example,
if we pass an argument of type number
to the identity
function, TypeScript will
infer the type argument for T
as number
.
However, there are cases when TypeScript cannot infer the type argument from the function call, especially when a generic construct is provided another generic construct whose type arguments are unknown. In such cases, TypeScript will default to assuming the unknown type for any type argument it cannot infer. This can lead to type errors if the inferred type argument is not compatible with the expected type.
To avoid defaulting to unknown, functions may be called with an explicit generic type argument that explicitly tells TypeScript what that type argument should be instead. TypeScript will perform type checking on the generic call to make sure the parameter being requested matches up to what’s provided as a type argument.
Generic Interfaces
Interfaces may be declared as generic as well. They follow similar generic rules
to functions: they may have any number of type parameters declared between a <
and >
after their name. That generic type may later be used elsewhere in their
declaration, such as on member types.
Inferred Generic Interface Types
As with generic functions, generic interface type arguments may be inferred from usage. When an interface with generic type parameters is used in a function or class, TypeScript can infer the type of the generic parameters based on the input arguments or the context in which the interface is used.
When we call boxValue
with a string argument, TypeScript can infer that the
type parameter T
should be string, and therefore the return type of boxValue
should be Box<string>
. The inferred type of boxedString
is Box<string>
.
Generic Type Aliases
One last construct in TypeScript that can be made generic with type arguments is type aliases
.
Generic Discriminated Unions
Generic Defaults
Sometimes, you might want to provide a default type for a generic parameter in case a specific type isn't provided. That's when generic defaults come into play.
Generic Constraints
Generic types by default can be given any type in the world: classes, interfaces, primitives, unions, you name it. However, some functions are only meant to work with a limited set of types.
You might want to make sure that the types being used as type arguments meet
certain conditions or have specific properties or methods. This is where generic
constraints come into play. You can use the extends
keyword to specify a
constraint for a generic type parameter.
Using Type Parameters in Generic Constraints (keyof)
Sometimes, you might want to reference other type parameters in your generic constraints. This allows you to create more dynamic and flexible constraints that depend on other type parameters.
Using extends and keyof together allows a type parameter to be constrained to the keys of a previous type parameter. It is also the only way to specify the key of a generic type.
Mapped Types
Mapped types iterate over the keys of an existing type, applying a transformation to each property. This allows you to create new types that are derived from other types, such as making all properties readonly, optional, or transforming their types.
Let's break down the syntax:
{}:
The curly braces are used to define an object type.[K in keyof OriginalType]
: This part defines the iteration over the properties ofOriginalType
.keyof OriginalType
returns a union of the property names (keys) ofOriginalType
. TheK
variable represents each property key in this iteration.NewProperty
: This is the new property type that will be applied to each property of the inputOriginalType
. You can use the current property key K and the property type fromOriginalType
to define the transformed property type.
- Mapped types based on existing literals of unions
- Transforming Property Types:
- Changing Modifiers
- Removing modifiers
- Generic Mapped Types
- Key Remapping via
as
In TypeScript 4.1 and later, you can use the as
keyword in mapped types to
perform key remapping. Key remapping allows you to change the keys of an object
type during the mapping process.
- Mapped Types for Tuples
Mapped types are primarily designed for objects. However, you can still apply mapped types to tuples in some cases, as tuples are essentially objects with numeric keys.
- Variadic Tuple Types
A way to represent a tuple with a varying number of elements of different types. This feature allows you to express higher-order operations on tuples and arrays even when their lengths are unknown.
Conditional Types
TypeScript’s type system is an example of a logic programming language. It allows creating new constructs (types) based on logically checking previous types. It does so with the concept of a conditional type: a type that resolves to one of two possible types, based on an existing type.
Conditional type syntax looks like ternaries:
Let's break down the syntax:
LeftType extends RightType
: This is the condition we're checking. We want to see if the input typeLeftType
extends (or is assignable to) the typeRightType
.? IfTrue : IfFalse
: This is a ternary expression, similar to those found in many programming languages. If the condition is true (i.e.,LeftType
extendsRightType
), the resulting type will beIfTrue
. If the condition is false, the resulting type will beIfFalse
.
The logical check in a conditional type is always on whether the left type extends, or is assignable to, the right type.
- Generic Conditional Types
- Type Distributivity
Conditional types automatically distribute over union types. This means that if the checked type is a union, the conditional type will be applied to each member of the union separately:
- Inferred Types (
infer
)
The infer
keyword in TypeScript is used specifically within the context of
conditional types. It allows you to infer a type from another type within a
conditional type expression. Essentially, it provides a way to capture and use
the inferred type within the scope of the conditional type expression.
T extends (infer E)[]
: We check ifT
is an array type. If it is, we use theinfer
keyword to capture the type of the array elements and assign it to the type variableE
.? E : never
: This is a ternary expression. If the condition is true (i.e.,T
is an array type), the resulting type will beE
, which is the inferred element type. If the condition is false (i.e.,T
is not an array type), the resulting type will benever
.
-
T extends Promise<infer R>
: We check ifT
is a Promise type. If it is, we use theinfer
keyword to capture the type of the resolved value and assign it to the type variableR
. -
? R : never
: This is a ternary expression. If the condition is true (i.e.,T
is a Promise type), the resulting type will beR
, which is the inferred resolved value type. If the condition is false (i.e.,T
is not a Promise type), the resulting type will benever
. -
Mapped Conditional Types
Mapped types apply a change to every member of an existing type. Conditional types apply a change to a single existing type. Put together, they allow for applying conditional logic to each member of a generic template type.