Union Types
Sometimes we need a value that could be multiple types. For example, we might have a function that measures distance in 2D space. The function accepts either a Tuple of number
s or an object for the x and y coordinates.
const distance1 = measureDistance([1, 2], [1, 3]); // 1
const distance2 = measureDistance({ x: 1, y: 1 }, { x: 1, y: 3 }); // 2
The problem comes when we need to annotate our function. How do you annotate a value that could be either one type or another?
This is where Union types come in. Union types represent values that could be one of any number of types. We’ve already seen them a couple of times in the course, such as when we used optional chaining to get a value that was either a string
or undefined
.
For our distance formula example, we’ll put a vertical bar ( |
) between the types to create a Union type that represents both.
interface CoordinateInterface {
x: number;
y: number;
}
type CoordinateTuple = [number, number];
type Coordinate = CoordinateInterface | CoordinateTuple;
This reads like “The Coordinate type is either the CoordinateInterface type or the CoordinateTuple type”.
Then, in the function which calculates the distance, we have to do a bit of type narrowing to determine whether the value is a Tuple or an object. We’ll create a helper function and use Array.isArray
to check if it is a Tuple.
function extractXY(point: Coordinate): CoordinateInterface {
if (Array.isArray(point)) {
return { x: point[0], y: point[1] };
} else {
return point;
}
}
Then we can use the distance formula to calculate the distance between the points.
function measureDistance(point1: Coordinate, point2: Coordinate): number {
const { x: x1, y: y1 } = extractXY(point1);
const { x: x2, y: y2 } = extractXY(point2);
// Distance Formula
return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
}
Union Types with Common Fields
When we learned about Interfaces, we extended our EdibleThing
type to create Fruit
and Vegetable
. Lets turn that around and create a Union of Fruit
and Vegetable
that represents both types.
interface Fruit {
name: string;
sweetness: number;
}
interface Vegetable {
name: string;
hasSeeds: boolean;
}
type EdibleThing = Fruit | Vegetable;
Since both members of the EdibleThing
union have a name:string
field on them, we can access it without TypeScript complaining. However, if we try to access the sweetness
or hasSeeds
properties without narrowing our type to either Fruit
or Vegetable
, TypeScript will throw a type error.
function checkForSeeds(food: EdibleThing) {
console.log(food.hasSeeds);
// Type Error: Property 'hasSeeds' does not exist on type 'EdibleThing'.
// Property 'hasSeeds' does not exist on type 'Fruit'.
}
function checkForSeeds(food: EdibleThing) { console.log(food.hasSeeds); }
TypeScript is telling us that at least one of the members of the EdibleThing
union doesn’t have the necessary property, so it throws a type error. We can’t even try to access that property without first performing some type narrowing. We can use the JavaScript in
keyword to find out if an object has a particular property.
function checkForSeeds(food: EdibleThing) {
if ("hasSeeds" in food) {
console.log(food.hasSeeds);
}
}
Union Types with null
or undefined
One of the most valuable uses of Union types is when working with null
and undefined
when the TSConfig strictNullChecks
flag is on. In fact, you might not have realized it, but TypeScript has already inferred a Union type for us. Any time we use an optional property, optional parameter, or optional chaining, TypeScript automatically infers that as a union with undefined
. We can easily check if the value is not undefined
with a truthy evaluation.
interface Fruit {
name: string;
sweetness?: number;
}
function getSweetness(fruit?: Fruit): number {
const sweetness = fruit?.sweetness; // const sweetness: number | undefined
if (sweetness) {
return sweetness;
}
throw Error("'sweetness' is undefined");
}
There are a lot of other interesting things we can do with Union types. We’ll cover some of these use cases in future sections.
Intersection Types
Let’s say we have two types that are different, but similar enough that we might want to combine them together.
interface Fruit {
name: string;
sweetness: number;
}
interface Vegetable {
name: string;
hasSeeds: boolean;
}
interface EdibleThing {
name: string;
sweetness: number;
hasSeeds: boolean;
}
That seems a bit redundant. What if we were able to combine these types together?
If Union types are either one type or another, Intersection types are one type and another. Intersection types combine the properties and features of two types together. Let’s go back to our Fruit
and Vegetable
example.
type EdibleThing = Fruit & Vegetable;
let banana: EdibleThing = { name: "Banana", sweetness: 70 };
// Type Error: Type '{ name: string; sweetness: number; }' is not assignable to type 'EdibleThing'.
// Property 'hasSeeds' is missing in type '{ name: string; sweetness: number; }' but required in type 'Vegetable'.
let apple: EdibleThing = { name: "Apple", sweetness: 80, hasSeeds: true }; // This works
This is especially helpful when combining interfaces and other object-like types. You can’t combine everything though. Combining primitives will always yield a never
type or a type that is impossible to satisfy.
type Strnumbering = string & number; // type Strnumbering = never;
type NumberAndNumberArray = number & number[];
let testNum1: number & number[] = 5; // Type Error: Type 'number' is not assignable to type 'number[]';
let testNum2: number & number[] = [5]; // Type Error: Type 'number[]' is not assignable to type 'number';
Since TypeScript also combines the types of properties with the same name, we have to be careful when combining objects with a similar shape but different types.
interface Fruit {
name: string;
sweetness: number;
}
interface Candy {
name: string;
sweetness: string;
}
type SweetThing = Fruit & Candy;
const apple: SweetThing = { name: "Apple", sweetness: 80 };
// Type Error: Type 'number' is not assignable to type 'never'.
In this case, our two sweetness
properties have different primitive types, and combining them created a never
type, which made it impossible to assign anything to variables of that type.
Literal Types
Literal types represent exact values of JavaScript primitives. For example, string
s can represent any string, but type literalString = "thisString"
can only represent a string with the value of “thisString”.
let fruitName: "Apple" = "Apple";
fruitName = "Banana"; // Type Error: Type '"Banana"' is not assignable to type '"Apple"'.
This behavior is inferred automatically when we use const
to declare our variables, since that variable will never change to something different.
const fruitName = "Apple";
// const fruitName: "Apple"
A variable that can only be assigned one value isn’t very interesting. Literal types become much more powerful when we combine them with Union types. Remember when we talked about Enums, we created an object to represent our constant values.
const SEASONS = {
winter: "winter",
spring: "spring",
summer: "summer",
autumn: "autumn",
};
We then used an Enum to make this object more type safe. It isn’t very convenient, though, since we always have to reference the SEASONS
object or Enum to get at the value we want. Using a Union of literal types allows us to have the same type safety as Enums, but without the extra hassle of accessing our values on the Enum itself.
type Seasons = "spring" | "summer" | "autumn" | "winter";
function seasonMessage(season: Seasons) {
if (season === "summer") {
return "The best season.";
}
return "It's alright, I guess.";
}
seasonMessage("autumn"); // It's alright, I guess.
seasonMessage("fall"); // Type Error: Argument of type '"fall"' is not assignable to parameter of type 'Seasons'.
Literal types can be created with number
s and boolean
s too.
type JazzBasketballRetiredJerseys = 1 | 4 | 7 | 9 | 12 | 32 | 35 | 53 | 1223;
type JazzFan = true;
We can use literal types in Interfaces and object types as well.
interface Fruit {
foodType: "fruit";
name: string;
sweetness: number;
}
This pattern of putting a literal property on an interface becomes especially useful when creating Discriminating Unions, which we’ll cover in a future section.
(Practice) Union & Literal Types
Below is a CodeSandbox link which has some TypeScript code. Your job is to add all of the appropriate literal and union type annotations to the variables and values so all of the TypeScript warnings go away.
Good luck!
Union and Literal Types CodeSandbox
(Solution) Union & Literal Types
Solution
Union and Literal Types CodeSandbox Solution
(Project) Starting Template
This project uses the DOM APIs built into web browsers. If you aren’t familiar with creating web apps with DOM APIs, check out this post to get up to speed.
The starting point of this video can be found with this CodeSandbox link.
You can also find the the starting point code on Github. The commit for this video can be found here.
(Project) Adding Initial Types
The starting point of this video can be found with this CodeSandbox link.
You can also find the the starting point code on Github. The commit for this video can be found here.
(Project) Cell Event Handler
The starting point of this video can be found with this CodeSandbox link.
You can also find the the starting point code on Github. The commit for this video can be found here.
(Project) Win Condition
The starting point of this video can be found with this CodeSandbox link.
You can also find the the starting point code on Github. The commit for this video can be found here.
Class Definition
Classes are a type of object which was introduced to JavaScript in ES2015. They provide “syntactic sugar” around JavaScript’s prototypical inheritance system, making it easier to create object instances that all behave the same. This means each instance of a class has the same property structure and the same methods.
One of the reasons TypeScript was invented in the first place was to add a class syntax to JavaScript and make it more object-oriented. Now that classes are part of the JavaScript language, TypeScript fully supports them.
We define a class using the class
keyword. We can then add properties to the class, along with their type definitions. Classes also need a constructor which initializes the class instance when it is created. Inside of class methods (another name for functions inside the class) we can use the this
variable to access our class instance.
class Fruit {
name: string;
color: string;
sweetness: number;
constructor(name: string, color: string, sweetness: number) {
this.name = name;
this.color = color;
this.sweetness = sweetness;
}
fullName() {
const isSweet = this.sweetness > 50;
return `${isSweet ? "Sweet " : ""}${this.color} ${this.name}`;
}
}
This class has four members - three properties ( name
, color
, sweetness
) and one method ( fullName
).
Classes might seem very similar to Interfaces. They let you give a name to a particular shape of object and assign types to the properties of that object. However, interfaces only represent a type; classes represent both the type of a class instance and a constructor function.
When we check the typeof
value of our class definition, it appears as a function
. This represents the constructor, which can be called to create an instance of the class. However, we can’t just invoke the class constructor function; we have to use the new
keyword so JavaScript knows to link up the class properties and methods to the class instance.
typeof Fruit; // "function"
const apple = Fruit("Apple", "red", 80); // Type Error: Value of type 'typeof Fruit' is not callable. Did you mean to include 'new'?
const banana = new Fruit("Banana", "yellow", 70); // This works
banana.fullName(); // "Sweet yellow Banana"
We can use the class type as an annotation, just like we would an Interface.
const fruitBasket: Fruit[] = [];
fruitBasket.push(new Fruit("Pear", "green", 60));
If we have the strictPropertyInitialization
flag turned on in our tsconfig.json file, TypeScript will expect us to initialize all of our class properties in the constructor. If we don’t, TypeScript will throw a type error.
class Fruit {
name: string;
color: string;
// Type Error: Property 'color' has no initializer and is not definitely assigned in the constructor.
sweetness: number;
// Type Error: Property 'sweetness' has no initializer and is not definitely assigned in the constructor.
constructor(name: string, color?: string, sweetness?: number) {
this.name = name;
if (color) {
this.color = color;
}
if (sweetness || sweetness === 0) {
this.sweetness = sweetness;
}
}
}
There are three ways we can avoid constructor initialization. They are default values, optional properties, and the non-null assertion operator.
Default properties let us specify what the value of a property should be in case it isn’t assigned in the constructor.
class Fruit {
name: string;
color: string = "red";
sweetness: number = 50;
constructor(name: string, color?: string, sweetness?: number) {
this.name = name;
if (color) {
this.color = color;
}
if (sweetness || sweetness === 0) {
this.sweetness = sweetness;
}
}
}
Optional properties work the same in classes as they do in Interfaces. Putting ?
after the property name will mark the property as optional. When we try to access these properties before they are assigned, their value will be undefined
. Also, remember that optional properties only exist in TypeScript; you can’t use them when writing JavaScript.
class Fruit {
name: string;
color?: string;
sweetness: number = 50;
constructor(name: string, sweetness?: number) {
this.name = name;
if (sweetness || sweetness === 0) {
this.sweetness = sweetness;
}
}
}
Another TypeScript-only solution is the Non-null assertion operator. It looks like an optional property, except with an exclamation mark ( !
) instead of a question mark. Adding this bypasses the strictPropertyInitialization
rule for that property, which means if we aren’t careful, the property might never be initialized and it would have the value of undefined
at runtime. Only use this when you are certain you know better than the type checker.
class Fruit {
name: string;
sweetness!: number;
constructor(name: string, sweetness?: number) {
this.name = name;
this.initSweetness(sweetness);
}
initSweetness(sweetness?: number) {
if (!sweetness) {
this.sweetness = 100;
}
this.sweetness = sweetness;
}
}
Inheritance
Just like Interfaces, classes can be extended by other classes, creating an inheritance chain. This behaves just a little differently than interfaces, though, since we have to account for the constructor function. Lets create the same class inheritance that we did earlier with Interfaces and adjust it for our class.
class EdibleThing {
name: string;
color: string;
constructor(name: string, color: string) {
this.name = name;
this.color = color;
}
}
class Fruit extends EdibleThing {
sweetness: number;
constructor(name: string, color: string, sweetness: number) {
super(name, color);
this.sweetness = sweetness;
}
}
super
is a special function which is only available in the constructor of a class which inherits from another class. It calls the constructor of the parent class. This lets us pass the necessary parameters to the parent’s constructor so it can prepare the class instance. Because of the rules of JavaScript, we have to call super
before trying to access this
. Otherwise, TypeScript will give us this warning: 'super' must be called before accessing 'this' in the constructor of a derived class.
Static Properties
We can assign static properties to a class. These are properties which only exist on the class definition, not class instances. We do this by appending the static
keyword in front of the property name.
class Vegetable {
static cookingTimeSeconds = 5;
static cook(vegetable: Vegetable) {
setTimeout(() => {
console.log(`Cooked ${vegetable.name}`);
}, Vegetable.cookingTimeSeconds * 1000);
}
name: string;
constructor(name: string) {
this.name = name;
}
}
const squash = new Vegetable("squash");
Vegetable.cook(squash); // 5 seconds later: "Cooked squash"
This is useful when we have utility functions or constants related to a specific class that we want easy access to.
Abstract Classes
Abstract classes are classes which cannot be instantiated, but provide implementation details for any classes which extend them. This differs from Interfaces, which only provide type definitions. You can think of them as being blueprints, or templates, which have to be followed when creating certain types of class definitions. They only exist in TypeScript, so you can’t use an abstract class when you are writing JavaScript.
Abstract classes can include abstract methods, which include a function type signature, but contain no implementation. Abstract methods must be implemented by the derived class; if the derived class doesn’t implement every abstract method, TypeScript will throw an error.
abstract class EdibleThing {
name: string;
abstract eat(): void;
constructor(name: string) {
this.name = name;
}
}
class Fruit extends EdibleThing {
constructor(name: string) {
super(name);
}
eat() {
console.log(`Yum. ${this.name}s are tasty.`);
}
}
Modifiers
If you’ve ever written code in Java or C#, then you are probably familiar with class modifiers. These are keywords that we place before our property and method definitions which define how and when the fields on our class can be accessed. We can use modifiers to tell the TypeScript type checker how to treat our class members. The three class modifiers are public
, private
, and protected
.
Public
By default, class fields are marked as public
. This makes the fields accessible both inside and outside of the class.
class Fruit {
name: string; // `public` modifier is implied
public sweetness: number;
constructor(name: string, sweetness: number) {
this.name = name;
this.sweetness = sweetness;
}
public sayName() {
console.log(this.name);
}
}
const apple = new Fruit("Apple", 80);
apple.name = "Banana";
apple.sayName(); // "Banana"
This is useful when we want to easily edit and modify properties or call methods outside of the class, but also gives any consumers of this class access to all of the class internals.
Protected
What if we were writing a class that will be used by other developers. Many of the parts of the class should be accessed by other developers, but other parts contain implementation details that we would prefer to keep to ourselves. We can use the protected
and private
modifiers to stop other developers from accessing certain properties and methods.
The protected
modifier limits access to class properties and methods to the class itself and any classes that inherit the parent class. If we try to access a protected
property or method, TypeScript will throw a type error.
class EdibleThing {
protected name: string;
constructor(name: string) {
// We can set and modify the name from within this class
this.name = name;
}
}
class Fruit extends EdibleThing {
protected sweetness: number;
constructor(name: string, sweetness: number) {
super(name);
this.sweetness = sweetness;
}
public sayName() {
console.log(this.name);
}
}
const apple = new Fruit("Apple", 80);
apple.name = "Banana"; // Type Error: Property 'name' is protected and only accessible within class 'EdibleThing' and its subclasses.
apple.sayName(); // "Apple"
We can access the public sayName
method, which accesses the protected name
property on the parent class, but TypeScript warns us if we try to access protected name
. However, if we were to ignore the warning and compile our TypeScript to JavaScript, it would still be able to access the property. TypeScript doesn’t add any runtime protections to protected
class fields, so JavaScript doesn’t know that we shouldn’t be allowed to access the property.
Private
private
is the most restrictive of the class modifiers. It only allows access to properties on the class they are defined on. Trying to access the field in child classes and outside the class causes TypeScript to throw a type error.
class EdibleThing {
private name: string; // Notice the modifier has been changed
constructor(name: string) {
this.name = name;
}
}
class Fruit extends EdibleThing {
protected sweetness: number;
constructor(name: string, sweetness: number) {
super(name);
this.sweetness = sweetness;
}
public sayName() {
console.log(this.name); // Type Error: Property 'name' is private and only accessible within class 'EdibleThing'.
}
}
readonly
readonly
is another modifier which is not limited to classes. You can use it on Interfaces, objects, arrays, or any other type as well. Adding this modifier to a property makes it so you cannot mutate or make changes to the property whatsoever. You can think of this as the const
variable declaration, but for object properties.
type readOnlyFruit = { readonly name: string };
const apple: readOnlyFruit = { name: "Apple" };
apple.name = "Banana"; // Type Error: Cannot assign to 'name' because it is a read-only property.
You can apply multiple layers of readonly
modifiers to further limit what can be changed.
type fruitBasket = {
readonly fruitList: readonly string[];
};
const basket: fruitBasket = { fruitList: ["Apple"] };
basket.fruitList[0] = "Banana"; // Type Error: Index signature in type 'readonly string[]' only permits reading.
Property Shorthand
TypeScript gives us a shorthand when our constructor parameters are the same as our properties. By assigning a modifier to constructor parameters, TypeScript will automatically assign them to the appropriate property without us even having to define the property on the class. This can save us a bit of typing.
class Fruit {
constructor(public name: string, protected sweetness: number) {}
}
const apple = new Fruit("Apple", 80);
console.log(apple); // Fruit { name: "Apple", sweetness: 80 }
A Note about JavaScript support
Everything we’ve talked about class modifiers so far only exists in TypeScript. If you try to use these features when writing JavaScript, you will get a syntax error. Everything else in this section is a feature of JavaScript, and you can use them so long as you don’t include the class field modifiers.
Accessors (get and set)
JavaScript provides the ability to add “accessors” to classes. These define virtual properties which call functions whenever a class property is read or changed. These functions have full access to the class instance and can read, transform, and modify any other instance property. We define accessors using the get
and set
keyword.
Here, we’ll create a class that ensures the name is always capitalized.
class Fruit {
constructor(protected storedName: string) {}
set name(nameInput: string) {
this.storedName = nameInput;
}
get name() {
return this.storedName[0].toUpperCase() + this.storedName.slice(1);
}
}
const apple = new Fruit("apple");
apple.name; // "Apple"
apple.name = "banana";
apple.name; // "Banana"
Notice that I store the name on a different property. If I were to try mutating this.name
within my set name()
method, it would call set name()
again, creating an infinite recursive function.
Class accessors which only use get
but not set
are automatically marked as readonly
by TypeScript.
class Fruit {
protected storedName: string;
constructor(name: string) {
this.storedName = name;
}
get name() {
return this.storedName[0].toUpperCase() + this.storedName.slice(1);
}
}
const apple = new Fruit("apple");
apple.name = "banana"; // Type Error: Cannot assign to 'name' because it is a read-only property.
Adding set
to classes can be helpful when we want to validate the input before assigning it to the class.
class Fruit {
constructor(protected name: string, protected storedSweetness: number) {}
get sweetness() {
return this.storedSweetness;
}
set sweetness(sweetnessValue: number) {
if (sweetnessValue < 0) {
throw new Error("Sweetness cannot be less than 0");
}
if (sweetnessValue > 100) {
throw new Error("Sweetness cannot be greater than 100");
}
this.storedSweetness = sweetnessValue;
}
}
Here, we throw an error if the provided value is too high or too low.
ES Private Fields
ES Private Fields are a relatively new addition to the JavaScript language, and are distinctly different from TypeScript private fields. Since ES Private Fields are part of the JavaScript language, they have runtime guarantees which TypeScript cannot provide. If you mark a field as an ES Private Field, that field will not be accessible outside of the class instance.
ES Private Fields are indicated with a pound sign hashtag octothorpe number sign ( #
) immediately before the property name. They are accessed in the same way, which means you can have getters and setters that publicly expose the ES Private Field with the same name.
class Fruit {
#name: string;
constructor(name: string) {
this.#name = name;
}
get name() {
return this.#name[0].toUpperCase() + this.#name.slice(1);
}
set name(name: string) {
this.#name = name;
}
}
const apple = new Fruit("apple");
console.log(apple.name); // "Apple"
apple.#name = "banana"; // Type Error: Property '#name' is not accessible outside class 'Fruit' because it has a private identifier.
private
or #private
?
There are a number of factors that go into whether you should use TypeScript’s private
modifier or the ES Private Field for a specific property.
ES Private Fields provide “hard privacy”, which means an outside viewer can’t see those properties even if they wanted to. This stops consumers of the class from relying on internal fields, making it easier for you to change your implementation without disrupting how users consume the API. Also, classes that extend your class still won’t have access to the private fields, which can help you avoid field naming collisions.
By contrast, TypeScript’s private
modifier provides “soft privacy”, allowing consumers of the class to read and edit those fields at runtime. This might be helpful for users that need to work around your API or dive in to see how the internals work.
As a rule of thumb, ES Private fields are generally what you want to use. However, if you can’t target ES Next when you compile your code, or if you can’t afford to have a lot of polyfill code, TypeScript’s private
modifier is a good alternative. Also, if you need classes that extend the base class to access a private property, you should use the protected
modifier.
(Practice) Classes
Below is a CodeSandbox link which has some TypeScript code. Your job is to define two classes. There are a set of tests that automatically run when you make changes to the classes. Follow the instructions in the test names to create the classes so the tests pass. Once you are done making the tests pass, make sure there are no TypeScript errors in the classes.test.ts
file.
Good luck!
(Solution) Classes
Solution
TypeScript Operators
When working with TypeScript, we deal with two kinds of things: values and types.
Values are the only thing we see in JavaScript. When working with values, we use variables to store them and assign them using a single equals ( =
). We can use operators to transform a value into some other value. Here’s a simple example.
const theAnswer: number = 15 + 27;
Here, we’re taking two number values, 15
and 27
, and adding them together with the addition ( +
) operator. We’re putting them in a variable called theAnswer
which has a number
type.
TypeScript types work very similarly. Types can be placed into type
aliases, and we can use operators, such as the Union operator ( |
) and Intersection operator ( &
) to transform them.
type NumberOrString = number | string;
TypeScript gives us a few more operators which we can use to transform types and even derive types from values.
Type Indexed Access
Suppose you have an interface which goes a few levels deep. You want to access the type of one of those deep levels, but you don’t want to have to type it out again. And the way you are using it is a one-off, so it seems silly to create an interface just for this one type.
Using Type Indexed Access, we can grab any type from any property of any other type.
interface Fruit {
name: string;
color: string;
nutrition: { name: string; amount: number }[];
}
type FruitNutritionList = Fruit["nutrition"];
We can even grab the type of individual items in an array. We can access a specific index, or we can use the number
type to grab the type of every array value.
type NutritionFact = Fruit["nutrition"][0];
// Alternatively
type NutritionFact = Fruit["nutrition"][number];
typeof
Sometimes we might want to use the type of some runtime value to represent the type of another thing. This could be especially helpful when the type was inferred by TypeScript.
let rectangle = { width: 100, height: 200 };
let rectangle2: rectangle; // Type Error: 'rectangle' refers to a value, but is being used as a type here. Did you mean 'typeof rectangle'?
Uh oh. It looks like you can’t use values as types. Fortunately, the type error gives us the solution we are looking for.
Just like we can use the typeof
operator in JavaScript to check the type of something at runtime, we can use the typeof
operator to derive the type of a value at compile time.
let rectangle = { width: 100, height: 200 };
let rectangle2: typeof rectangle; // { width: number; height: number; }
keyof
It can also be helpful to get the different keys which are present on a type, such as when we are dynamically accessing properties. We can use the keyof
operator to give us a type which represents all of these property names. In reality, it’s a union type of string literals, one for each property name.
interface Rectangle {
width: number;
height: number;
}
type RectangleProperties = keyof Rectangle; // type RectangleProperties = "width" | "height"
let rectangle: Rectangle = { width: 100, height: 200 };
const propertyName: RectangleProperties = "height";
console.log(rectangle[propertyName]); // 200
We can even combine keyof
with typeof
to get a union type with the property names of an object values.
let rectangle = { width: 100, height: 200 };
type RectangleProperties = keyof typeof rectangle; // type RectangleProperties = "width" | "height"
const
assertions
TypeScript is very loose with how it interprets our literal types. For example, when we create an object literal, TypeScript infers that the types of the values are primitives, like string
and number
, not literal types, like "hello"
and "5"
.
let rectangle = { width: 100, height: 100 };
// let rectangle: {
// width: number;
// height: number;
// }
This is typically what we want - it would be counterproductive for every value to be immutable - but sometimes, we might want to make the properties literal. We would have to use a literal type annotation to do this, in addition to setting the value.
let rectangle: { width: 100; height: 100 } = { width: 100, height: 100 };
// let rectangle: {
// width: 100;
// height: 100;
// }
While that does get the job done, it’s very verbose - we’re literally repeating our object literal.
When we have any value that we are assigning that we want to be inferred as a literal type, we can use what’s called a const
assertion. Remember, an assertion is a notice to the type checker that tells it more about our program so it can check our code properly. We write const
assertions by putting as const
after the literal value.
let rectangle = { width: 100, height: 100 } as const;
// let rectangle: {
// readonly width: 100;
// readonly height: 100;
// }
This has roughly the same effect as writing out the type annotation, but with much less code. It does add a readonly
modifier, to indicate that these values are constant.
If we only wanted to make one of the values a literal type, we could do that in the object declaration. We can also make individual variable values literal with the same assertion.
let rectangle = { width: 100, height: 100 as const };
// let rectangle: {
// width: number;
// height: 100;
// }
let message = "Hello" as const;
// let message: "Hello"
This actually looks very similar to Enums. In fact, we can achieve the same level of type safety that Enums provide without all the ceremony. Just for fun, here’s an Enum, and then a similar object created with a const
assertion. When we use our object in a properly annotated function, we get great type safety.
enum Seasons {
winter,
spring,
summer,
autumn,
}
const SEASONS = {
winter: "winter",
spring: "spring",
summer: "summer",
autumn: "autumn",
} as const;
function seasonsGreetings(season: typeof SEASONS[keyof typeof SEASONS]) {
if (season === SEASONS.winter) return "⛄️";
if (season === SEASONS.spring) return "🐰";
if (season === SEASONS.summer) return "🏖";
if (season === SEASONS.autumn) return "🍂";
}
Our seasonsGreetings
function parameter has just as much type safety as if we had used an Enum. In fact, our fancy type signature that we used ( typeof SEASONS[keyof typeof SEASONS]
) equates to a Union of literal values, one for each value in SEASONS
.
You recall that a literal array value will always inferred to be an array of whatever types we used in the literal, like so.
const assortedItems = ["hello", 5, (fruit: Fruit) => {}];
// const assortedItems: (string | number | ((fruit: Fruit) => void))[]
What a mess! Our array item type is a Union of string
s, number
s, and that specific function signature. This is the same problem we experienced with Tuples, where TypeScript would never infer them to be a fixed-length array of specific types; we always had to add a Tuple type annotation.
What would happen if we used a const
assertion on our array literal?
const assortedItems = ["hello", 5, (fruit: Fruit) => {}] as const;
// const assortedItems: readonly ["hello", 5, (fruit: Fruit) => void]
Just adding as const
to our array literal has turned it into a Tuple, without us having to add an explicit annotation. Our string
and number
literals stayed literal in our Tuple definition. If we were to use string
and number
variables, the types would be passed into the Tuple definition.
let message = "hello";
let count = 5;
const assortedItems = [message, count, (fruit: Fruit) => {}] as const;
// const assortedItems: readonly [string, number, (fruit: Fruit) => void]
Hopefully const
assertions can help simplify, or even eliminate the need, for some of your type assertions.
(Bonus) Advanced Function Typing
We’re not done learning how to add types to functions.
Overloaded Functions
Sometimes you have a single function which accepts different counts of arguments or different argument types. We could easily type something like that with Union types and optional parameters. In fact, if the function is simple, it can be better to type them this way.
function stringOrArrayLength(input: string | unknown[]) {
return input.length;
}
If we had more than a few arguments or types in our union, it could get messy. That’s where function overloading comes in.
We overload a function by adding multiple function type signatures above the function definition. If we were to overload the same function as above without using unions, it might look like this:
function stringOrArrayLength(input: string): number;
function stringOrArrayLength(input: unknown[]): number;
function stringOrArrayLength(input: { length: number }): number {
return input.length;
}
The last function signature is known as the “implementation signature”, and has to match the signature of all of the overloads. Since both string
and arrays have a length
property, I can use an object with a length
property, but it’s common to use any
for the parameters and narrow the type of each parameter in the function implementation.
You can add as many function signatures and parameters as you want, so long as the implementation signature matches all of them.
this
Parameter
When an object includes a function declaration, you can access the object with the special this
variable. TypeScript provides a way for you to apply a type annotation to this
so you can have type safety when accessing this
.
interface IceCreamSundae {
baseIceCream: string;
chocolateSyrup: number;
cherry: boolean;
}
const hotFudgeSundae = {
baseIceCream: "vanilla",
chocolateSyrup: 5,
cherry: true,
eat(this: IceCreamSundae) {
if (this.cherry) {
console.log("Mmmm. Tasty.");
} else {
console.log("Could be better.");
}
},
};
hotFudgeSundae.eat(); // 'Mmmm. Tasty.'
Remember, this
is a special parameter to functions, so we don’t have to pass it to our eat
function. When we compile this into JavaScript, the this
type annotation is removed.
const hotFudgeSundae = {
baseIceCream: "vanilla",
chocolateSyrup: 5,
cherry: true,
eat() {
if (this.cherry) {
console.log("Mmmm. Tasty.");
} else {
console.log("Could be better.");
}
},
};
Common Type Guards
We’ve finally come to one of the most important sections in the entire course. What do you do when you have an unknown
type and you want to use it in a meaningful way? What about a Union of several types? To do so safely requires us adding some runtime checks which prove to the TypeScript type checker that the value has a specific type. We call these runtime checks “type guards”.
We can create type guards using any kind of conditional - if
statements, switch
statements, ternaries, and a few others. We put some kind of check against the value’s type inside the if
statement’s condition. Then, inside the block, our value now has the type we matched it against.
Primitive types
The most straightforward check is strict equality.
function sayAlexsNameLoud(name: unknown) {
if (name === "Alex") {
// name is now definitely "Alex"
console.log(`Hey, ${name.toUpperCase()}`); // "Hey, ALEX"
}
}
We could pass literally any value to this function because of the type guard. It’s still type safe, since TypeScript knows that inside the if
block, name is a literal "Alex"
type, which has the same methods and properties as a string
.
That’s a little tedious if we needed to do that for literally every name. Earlier in the course, we used the typeof
operator to check what primitive type a value is. We can do that with any primitive type to narrow down a value to a particular type.
function sayNameLoud(name: unknown) {
if (typeof name === "string") {
// name is now definitely a string
console.log(`Hey, ${name.toUpperCase()}`);
}
}
We could also do the inverse of this. Instead of checking to see if name
is a string
, we can check if it is not a string
, and then return early or throw an error. Then we know that everywhere else in our function, name
is a string
function sayNameLoud(name: unknown) {
if (typeof name !== "string") return;
// name is now definitely a string
console.log(`Hey, ${name.toUpperCase()}`);
}
We could use switch
statements to check the type as well. In this case, we’ll narrow a Union type of number
and string
, but we could do the same thing with unknown
.
function calculateScore(score: number | string) {
switch (typeof score) {
case "string":
return parseInt(score) + 10;
break;
case "number":
return score + 10;
break;
default:
throw new Error("Invalid type for score");
}
}
typeof
can cover us in a lot of cases, but once we get to referential types, like objects, arrays, and class instance, we can’t use it anymore. Whenever we use typeof
of any of these types, it will always return “object”. We’ll have to use more sophisticated methods to determine the types of our values.
Arrays
We can check if a value is an array using the Array.isArray
method.
function combineList(list: unknown): any {
if (Array.isArray(list)) {
list; // (parameter) list: any[]
}
}
Our list is now a list of any
s, which is better than before, but we still don’t know the type of the values inside the list. To do that, we’ll have to loop through our array to narrow the type of each item. We can use the .filter()
and .map()
methods on our array to do that. We could also use a for
loop. Which you choose depends on your circumstances.
function combineList(list: unknown): any {
if (Array.isArray(list)) {
// This will filter any items which are not numbers.
const filteredList: number[] = list.filter((item) => {
if (typeof item !== "number") return false;
return true;
});
// This will transform any items into numbers, and turn `NaN`s into 0
const mappedList: number[] = list.map((item) => {
const numberValue = parseFloat(item);
if (isNaN(numberValue)) return 0;
return numberValue;
});
// This does the same thing as the filter, but with a for loop
let loopedList: number[] = [];
for (let item of list) {
if (typeof item == "number") {
loopedList.push(item);
}
}
}
}
Classes
We can determine whether a value is an instance of a specific class using the instanceof
operator.
class Fruit {
constructor(public name: string) {}
eat() {
console.log(`Mmm. ${this.name}s.`);
}
}
function eatFruit(fruit: unknown) {
if (fruit instanceof Fruit) {
fruit.eat();
}
}
Narrowing a value to a class immediately lets us know all of the properties and methods which are on the value.
Objects
Objects are a little trickier to narrow, since they could have any combination of properties and types. Most of the time we don’t care about the type of the entire object; all we want is to access one or two properties on the object.
We have already used the in
operator to determine whether a property exists on an object. We can then use the typeof
operator to determine that property’s type.
The in
operator only works to narrow union types, so we can’t use it with unknown
. Instead, we’ll have to use another special type that comes with TypeScript: object
. This type represents anything that isn’t a string
, number
, boolean
, or one of the other primitive types. Using object
instead of unknown
will tell TypeScript to let us attempt to access properties on this value. We can create a Union of the generic object
type and an Interface with the property that we want to access.
interface Person {
name: string;
}
function sayNameLoud(person: object | Person) {
if ("name" in person) {
console.log(`Hey, ${person.name.toUpperCase()}`);
}
}
In this case, TypeScript doesn’t care what other properties are on our person
type. All that matters is that it has a name
property which is a string
. We could actually abuse this quirk in the type checker to create code that looks type safe but throws a runtime type error.
interface Person {
name: string;
age: number;
}
function sayNameLoud(person: object | Person) {
if ("age" in person) {
console.log(`Hey, ${person.name.toUpperCase()}`);
}
}
sayNameLoud({ age: 27 });
The best way to avoid this is to check for all the properties that we actually use so we can guarantee that they are the type we want them to be.
Hopefully this illustrates that, while TypeScript is very sophisticated and will catch 98% of your type errors, there are still ways to unintentionally get around it and write unsafe code. In the next few sections, we’ll see more ways to fool the type checker and how to avoid doing that.
Handling null and undefined
Often, we can’t know for certain whether a variable actually has a value or if it is null
or undefined
. For example, we can use document.getElementById()
to get a DOM element with a specific ID. However, if no element exists with the ID that we provide, the function will return null
. If we have the strictNullChecks
flag in tsconfig.json turned on, TypeScript will warn us that the element might not exist and require us to perform a runtime check to make sure the value is present. This helps us avoid Cannot read property "x" of undefined
errors.
Lets use document.getElementById
to demonstrate possible ways to handle null
and undefined
values.
Truthy Evaluation
If we naïvely try to change the innerText
of an HTML element, we will likely get a type error.
document.getElementById("messageInput").innerText = "Alex";
// Type Error: Object is possibly 'null'.
The easiest and most commonly used type guard is an if
condition that checks truthiness. This means passing in the value to our conditional without doing any kind of check using strict equals ( ===
). JavaScript will allow the condition to pass if the value is truthy; if the value is falsy, it will not pass. If our conditional passes, TypeScript knows that inside of our conditional, the value cannot be null
or undefined
.
const messageInput = document.getElementById("messageInput");
if (messageInput) {
messageInput.innerText = "Alex";
}
Be aware that this also excludes legitimate values that are falsy, such as 0
, ''
, and false
. You’ll have to do additional checks if you expect your variable might be one of those falsy values.
Optional Chaining
Things can get verbose if we are trying to access properties deep within an object’s structure. We have to add several layers of if
statements, or use the logical AND operator ( &&
) to sort out whether the object property exists or not.
const messageInputElement = document.getElementById("messageInput");
if (messageInputElement) {
const parentElement = messageInputElement.parentElement;
if (parentElement) {
const messageInputParentInnerHTML = parentElement.innerHTML;
if (messageInputParentInnerHTML) {
// ...
}
}
}
Optional Chaining is a relatively new JavaScript operator which allows you to attempt to access properties on an object whether or not they actually exist. This can be really useful when we want to access deep properties of objects that might not actually exist. We can prepend a question mark to our “dot property access” ( .
) operator to create the Optional Chaining operator ( ?.
) that lets us safely access any property, even optional or undefined properties. If the property doesn’t actually exist or is null
or undefined
, JavaScript will quietly return undefined
for the whole expression.
const messageInputParentInnerHTML = document.getElementById("messageInput")
?.parentElement?.innerHTML;
messageInputParentInnerHTML; // const messageInputParentInnerHTML: string | undefined
Isn’t that nicer to read?
Our variable has a type of string | undefined
because if the expression evaluates all the way to the end, innerHTML
is a string; but if the expression fails anywhere along the way, either if .getElementById()
returned null or if our #messageInput
element doesn’t have a parent element, the entire expression will return undefined
. We can then do a truthiness check to see whether messageInputParentInnerHTML
is a string
or not.
const messageInputParentInnerHTML = document.getElementById("messageInput")
?.parentElement?.innerHTML;
if (messageInputParentInnerHTML) {
console.log(`The current contents are ${messageInputParentInnerHTML}`);
}
Also note that Optional Chaining only fails if the property is null
or undefined
; falsy values like 0
and false
will pass through correctly.
We can use optional chaining to optionally access items on arrays as well. We just prepend the optional chaining operator to the square brackets of our index access. In this next example, our API could correctly return a list of teachers, or it could return an error message.
function handleTeacherAPIResponse(response: {
teachers?: string[];
error?: string;
}) {
const firstTeacher = response.teachers?.[0];
firstTeacher;
}
Finally, we can optionally call methods on objects that may or may not exist. To do this, we prepend the optional chaining operator to the parentheses which call the function.
async function makeAPIRequest(url: string, log?: (message: string) => void) {
log?.("Request started.");
const response = await fetch(url);
const data = await response.json();
log?.("Request complete.");
return data;
}
If we were to not pass a value in for the log
function, the calls to that function would silently fail without throwing an error. It’s as if we wrapped the function call in a truthiness check for the function.
async function makeAPIRequest(url, log) {
if (log) {
log('Request started.)
}
// ...etc.
}
Nullish Coalescing
When you have the option, using a default value for variables that might be undefined is really helpful. Typically we use the logical OR operator ( ||
) to assign the default value.
const messageInputValue =
document.getElementById("messageInput")?.value || "Alex";
messageInputValue; // const messageInputValue: string;
The only problem with this approach is it checks against falsy values, not just null
and undefined
. That means when our #messageInput
field is empty, it will still give us the default value since empty string
is falsy.
The Nullish Coalescing operator solves this by only checking if a value is null
or undefined
. It works the same way as the logical OR, but with two question marks ( ??
) instead of two vertical bars.
const messageInputValue =
document.getElementById("messageInput")?.value ?? "Alex";
messageInputValue; // const messageInputValue: string;
Non-null Assertion
Sometimes, you just know better than the type checker. If you positively know that a particular value or property is not null
or undefined
, you can tell the type checker using the Non-null Assertion operator ( !.
), which looks a lot like the Optional Chaining operator. Using this operator essentially tells the type checker “There’s no way this property could possibly be null
or undefined
, so just pretend like it’s the correct type.”
const messageInputValue = document.getElementById("messageInput")!.value;
messageInputValue; // const messageInputValue: string;
Notice we don’t have to add a default value. TypeScript trusts that we know what we are doing and will give us the value with the appropriate type, no questions asked. Be aware that, unlike the other two operators, the Non-null assertion operator only exists in TypeScript. If you tried using it in JavaScript, it would throw a syntax error.
Remember, any time we override the default behavior of the type checker, we run the risk of introducing type errors that the type checker cannot catch for us. Using the Non-null Assertion operator removes the type safety which we wanted TypeScript to give us in the first place, so if it is at all possible, handle null
and undefined
properties with one of the other methods before resorting to Non-null Assertions.
(Practice) Narrowing Types
Below is a CodeSandbox link which has some TypeScript code. Your job is to narrow all of the types so the TypeScript warnings go away. Don’t change the type annotations unless the instructions tell you to - instead, use type narrowing to determine what the type actually is.
Good luck!
(Solution) Narrowing Types
Solution
(Bonus) Structural vs Nominal Typing
There might have been some times during the course when you noticed some interesting choices which we made as we wrote the examples. Take this example:
class Fruit {
isFruit = true;
constructor(public name: string) {}
}
class Apple extends Fruit {
type: "Apple" = "Apple";
constructor() {
super("Apple");
}
}
class Banana extends Fruit {
type: "Banana" = "Banana";
constructor() {
super("Banana");
}
}
We’re doing two things here which might look a little bit redundant.
- Our
Fruit
class has a propertyisFruit
set to the literaltrue
value. Why is this necessary when we can see the class is namedFruit
? - Our
Apple
andBanana
classes have atype
property set to the literalApple
andBanana
values. Why is this necessary when we can see those classes are namedApple
andBanana
?
The reason for both of these extra bits of data is structural typing. This is the typing strategy which TypeScript uses to evaluate whether two types are compatible. It focuses on the shape of objects, and is often called “duck typing” (if it has a beak like a duck and waddles like a duck, it’s probably a duck.) Structural typing is a good choice for TypeScript; even though all of the type information is removed when TypeScript code is compiled into JavaScript, we can still do runtime checks against the shape of our objects.
If we didn’t have the type
properties, our Apple
and Banana
types would be indistinguishable, since they all would share the same property: name:string
. Adding those extra properties helps us distinguish between them, both in the type system and at runtime.
One other thing to note is that different ways of describing structured types, such as classes, interfaces, and literal object types, are all structurally equivalent as well. All three of these types are equivalent in TypeScript.
class AppleClass {
type: "Apple";
name: string;
}
interface AppleInterface {
type: "Apple";
name: string;
}
type AppleType = {
type: "Apple";
name: string;
};
Other programming languages, like Java and C# use a nominal type system. The word “nominal” refers to the name of the thing, which means if I were to create two classes with an identical structure, but different names, those two classes would be considered different.
Like we saw with the Apple
and Banana
classes, we can emulate nominal typing by adding a unique property to our types with a string literal type. This practice is called “branding”, or “tagging”, and is what allowed us to differentiate between the two types in the type system.
When using a branded type, you want to make sure you assign your variables with the correct type; otherwise TypeScript will infer the type based on what value you put in your variable.
let banana = new Apple(); // const banana: Apple
let apple: Apple = new Banana(); // Type 'Banana' is not assignable to type 'Apple'.
This makes it possible to discriminate between types that may have the same structure, but different purposes.
We can also create what are called “branded primitives”. This allows us to create primitive values which are only assignable to variables and parameters that match a specific type signature.
We first create our branded primitive type. In this case, I’ll make a number that represents money from different countries.
type USD = number & { _brand: "USD" };
type EUR = number & { _brand: "EUR" };
Then, we can assign it to variables. If we just try assigning numbers to variables annotated with our branded types, TypeScript will give a warning.
let income: USD = 10; // Type Error: Type 'number' is not assignable to type 'USD'.
Instead, we have to use an assertion signature to convert our number type into the branded type. Then, we won’t be able to assign USD
numbers to EUR
variables without converting the type (and value if necessary) first.
let VAT = 10 as EUR;
function convertToUSD(input: EUR): USD {
return (input * 1.18) as USD;
}
let VATInUSD = convertToUSD(VAT); // let VATInUSD = USD;
Now, not only are our objects type safe, but our units are type safe too!
Discriminating Unions
Sometimes we might have two interfaces that are very similar in shape but with key differences. If we had a Union of those two interfaces, we could access any of the common properties without a problem. However, if we were to try accessing one of the properties that only exists on one of the Interfaces, TypeScript would warn us.
interface Fruit {
name: string;
color: string;
juice: () => void;
}
interface Vegetable {
name: string;
color: string;
steam: () => void;
}
type EdibleThing = Fruit | Vegetable;
function prepareEdibleThing(food: EdibleThing) {
food.juice();
// Type Error: Property 'juice' does not exist on type 'EdibleThing'.
// Property 'juice' does not exist on type 'Vegetable'.
}
TypeScript is telling us that juicing vegetables is gross a Vegetable
doesn’t have the property we’re looking for, and since the parameter we pass to the function could be a Vegetable
, we can’t access the property without doing more checks.
Using Optional Chaining doesn’t help, since food
is already guaranteed to not be null
or undefined
. One thing that does work is using the in
keyword.
function prepareEdibleThing(food: EdibleThing) {
if ("juice" in food) {
food.juice();
}
}
This works well when we only need to access a single property, but what if we needed to access many properties? Or what if we had more than two types in the Union?
This is where Discriminating Unions come into play. We can create a Discriminating Union by adding literal type properties to the interfaces which are part of the union. We can then check that individual property to see what type represents the value.
interface Fruit {
type: "fruit";
name: string;
color: string;
juice: () => void;
}
interface Vegetable {
type: "vegetable";
name: string;
color: string;
steam: () => void;
}
type EdibleThing = Fruit | Vegetable;
function prepareEdibleThing(food: EdibleThing) {
if (food.type === "fruit") {
food.juice();
}
if (food.type === "vegetable") {
food.steam();
}
}
This only works because all of the members of the Union have a similar property, but the property doesn’t have to be a literal type. As long as the type is different for each of the members, we can check against it. For example, TypeScript’s type checker is intelligent enough to determine the type when we use a truthiness check on a member that could be null.
type StringResult =
| { error: Error; data: null }
| { error: null; data: string };
function handleResult(result: StringResult) {
if (result.error) {
// Handle the error, which we know is an Error type
}
return result.data;
}
(Practice) Discriminating Unions
Below is a CodeSandbox link which has some TypeScript code. Your job is to add the appropriate properties and checks so all of the TypeScript warnings go away.
Good luck!
Discriminating Unions CodeSandbox
(Solution) Discriminating Unions
Solution
Discriminating Unions CodeSandbox Solution
Assertion Signatures
We’ll start this section off with an example. Lets suppose we’re trying to create a dynamically generated graphic using the Canvas API. We already have a <canvas>
element in the HTML of the page, so we can just access it using our DOM APIs. Once we have reference to the element, we can create a 2D drawing context, and then start making graphics.
TypeScript knows the types which are returned by document.getElementById
and canvas.getContext
, so we should be able to use them without applying any type annotations.
const canvas = document.getElementById("canvas");
const context = canvas.getContext("2d"); // Type Error: Property 'getContext' does not exist on type 'HTMLElement'.
Oh, that’s a bummer. It looks like document.getElementById
returns an HTMLElement
instead of the HTMLCanvasElement
that we need, even though we know the element is most certainly a <canvas>
element. Well, let’s try adding a type annotation to our canvas
variable.
const canvas: HTMLCanvasElement = document.getElementById("canvas");
// Type Error: Type 'HTMLElement' is missing the following properties from type 'HTMLCanvasElement': height, width, ...
Oh no! The HTMLElement
type which is returned by getElementById
is not assignable to a variable with the type HTMLCanvasElement
. That’s because HTMLElement
doesn’t have the necessary properties defined to make it compatible with HTMLCanvasElement
.
But we know that it is in fact a <canvas>
element! We just need a way to tell TypeScript that.
Assertion Signatures
To assert to the type checker that a value has a specific type, we just append the keyword as
, followed by the type we want to assert. This tells the TypeScript type checker that a certain value is in fact the type we say it is.
const canvas = document.getElementById("canvas") as HTMLCanvasElement;
const context = canvas.getContext("2d");
TypeScript trusts us to know what we’re doing, and now lets us access the properties on HTMLCanvasElement
.
Once again, this is a situation where we know more than TypeScript and get to override the behavior of the type checker. TypeScript isn’t doing any runtime checks for us to make sure that canvas
really is a HTMLCanvasElement
. Any time we do this, we run the risk of being wrong and creating type errors which the type checker can’t catch for us.
Still, Assertion signatures are safer than most methods, since the type checker will verify that the type we are asserting is at least similar to the original type. That keeps us from asserting that one type is a totally incompatible type.
let fruitName: number = "banana" as number;
// Type Error: Conversion of type 'string' to type 'number' may be a mistake because neither type sufficiently overlaps with the other.
The type checker is even smart enough to avoid conversions between interfaces that aren’t similar enough.
interface Car {
make: string;
model: string;
color: string;
}
interface Fruit {
name: string;
color: string;
sweetness: number;
}
let car: Car = { make: "Pontiac", model: "Sunfire", color: "silver" };
let fruityCar: Fruit = car as Fruit;
// Type Error: Conversion of type 'Car' to type 'Fruit' may be a mistake because neither type sufficiently overlaps with the other.
// Type 'Car' is missing the following properties from type 'Fruit': name, sweetness
Double Assertion Signatures
We’re about to dive into something that is exceptionally dangerous. Only do this if you are confident you won’t introduce any type errors.
We can convince TypeScript that any value of any type has any other type. It all starts by giving a value an assertion that it is unknown
. Any value can be converted into unknown
, so this isn’t very strange so far.
const ageInYears = "too old to count" as unknown;
ageInYears; // const ageInYears: unknown
But now, what if we were to add another assertion, turning that unknown type into a different type?
const ageInYears = ("too old to count" as unknown) as number;
ageInYears; // const ageInYears: number
We can even try to access number properties on our value. This, of course, will throw a type error.
ageInYears.toFixed(8); // Uncaught TypeError: ageInYears.toFixed is not a function
This is the most dangerous way to get around the type system, even more dangerous than any
. Not only does it open you up to unexpected type errors, but TypeScript can’t even warn you that they might happen. You’ve convinced it everything is fine.
This can be helpful when you are certain something should be a certain type and TypeScript isn’t letting you convert with a single assertion signature. This is especially useful when you are working with interfaces or third-party APIs which expect parameters to be passed as a certain type.
We should only convert a value’s type to unknown
if there is no other solution. It’s much safer for us to convert to a type that is common between the two different types. In this next example, I have a reusable buttonEventListener
function which only accepts HTMLButtonElement
values. However, I can use it with an HTMLAnchorElement
value by first converting my anchor
value into the common HTMLElement
type, and then converting that into an HTMLButtonElement
.
HTMLElement
is similar enough to both types that we can use it without TypeScript warning us, and our code will be more type safe than if we converted our type to unknown
, since at very least our value has to match the HTMLElement
type.
function buttonEventListener(
event: string,
listener: any,
element: HTMLButtonElement
) {
element.addEventListener(event, listener);
}
const anchor = document.createElement("a");
buttonEventListener("click", () => console.log("Mouse clicked"), anchor);
// Type Error: Argument of type 'HTMLAnchorElement' is not assignable to parameter of type 'HTMLButtonElement'.
buttonEventListener(
"click",
() => console.log("Mouse moved"),
(anchor as HTMLElement) as HTMLButtonElement
);
// no error
In this example, our buttonEventListener
function could be more expressive or allow more types to be used for element
, but our solution offers a good workaround.
User Defined Type Guards
This whole section has been about the type guards which we can use to narrow the type of a value to be more specific. Examples include typeof
, instanceof
, in
, and Array.isArray
. Each of these is able to assert to TypeScript that a value either does or does not conform to some type.
We can define our own user-defined type guards too. These can be especially helpful when checking the type of a value is a complicated process, or if we want to reuse the same runtime checking logic in multiple places.
Suppose we have two interfaces. We can’t modify these interfaces for some reason - perhaps they are part of a third-party library - so we can’t take advantage of Discriminating Unions. However, we still need some way to tell the difference between them.
interface Fruit {
name: string;
color: string;
sweetness: number;
}
interface Vegetable {
name: string;
color: string;
tenderness: number;
}
A user defined type guard is a function that takes at least one argument, returns a boolean, and has a type predicate return signature. This is a special type signature which says “this value is most certainly this type”.
function isFruit(maybeFruit: Fruit | Vegetable): maybeFruit is Fruit {
if ("sweetness" in maybeFruit) return true;
return false;
}
const tomato = { name: "Tomato", color: "red", tenderness: 70 };
if (isFruit(tomato)) {
console.log(`Tomato is ${tomato.sweetness}% sweet.`);
} else {
console.log(`Tomato is ${tomato.tenderness}% tender.`);
}
// "Tomato is 70% tender."
Our type predicate, maybeFruit is Fruit
, tells us that if this function returns true, then maybeFruit
is definitely a Fruit
. Since tomato
doesn’t have a sweetness
property, it is recognized as a Vegetable
, which lets us access the tenderness
property without warning.
We can define type guards on unknown
and any
types too, allowing us to easily narrow them to any type we like.
function isFruit(maybeFruit: any): maybeFruit is Fruit {
if ("color" in maybeFruit) return true;
return false;
}
While this would work some of the time, other times it might give us the wrong type. If we were to pass a Vegetable
to this function, it would transform its type into a fruit. What if the color
property were present on maybeFruit
, but it was actually a number instead of a string?
If we wanted to, we could use user defined type guard functions to trick the TypeScript compiler that any value is any other type. Obviously, that would cause type errors in our program that TypeScript couldn’t warn us about, so we should be as thorough as possible when writing the conditions in user defined type guards, especially if the type predicate is changing an any
or unknown
type to another type.
Assertion Functions
Assertion functions are another kind of type guard that use a different method to tell the type checker what type a value has. Assertion functions allow you to throw errors to assert a type condition. TypeScript isn’t sophisticated enough to know whether a function that throws an error asserts any kind of types on your values. We have to add an assertion signature to our assertion function to let TypeScript know that throwing an error proves something about a type.
There are two kinds of assertion signatures. The first type asserts that a boolean
argument is true. We have to pass in a argument, and then we can add asserts <parameter name>
as our function return signature. If the function doesn’t throw an error, it influences the type of the values used in the condition.
function assertTrue(condition: boolean): asserts condition {
if (!condition) {
throw new Error();
}
}
const maybeFruitName: unknown = "Banana";
assertTrue(typeof maybeFruitName === "string");
maybeFruitName; // const maybeFruitName: string;
This works in a similar way to using if
statements to return early, but it throws an error instead.
The second assertion signature is written more like a type predicate. It allows us to assert that if the function does not throw an error, a function argument is a specific type.
function assertIsFruit(maybeFruit: any): asserts maybeFruit is Fruit {
if (!("sweetness" in maybeFruit)) throw new Error();
}
const tomato = { name: "Tomato", color: "red", tenderness: 70 };
assertIsFruit(tomato);
tomato; // const tomato: Fruit
Both Assertion Functions and Type Predicates allow us to write functions which assert or prove something about the types of the values which are passed into them, giving us more flexibility with how we perform runtime type checks of our values.
(Practice) User Defined Type Guards
Below is a CodeSandbox link which has some TypeScript code. Your job is to implement some user defined type guards with type predicates and assertion functions so all of the TypeScript warnings go away.
Good luck!
User Defined Type Guards CodeSandbox