I decided to take a look at TypeScript this year. As I often do, I turn to Udemy for helpful courses and so found this great TypeScript course from the wonderful Maximilian Schwarzmüller.
The following is a summary of some notes I took during the course. To assist my learning I have created my own examples where possible. This is not a replacement for taking the course as the full course includes more practical tips and advice along with in-depth examples showing TypeScript used on a larger project.
The following should be a helpful resource to refer to after taking the course.
Table of contents
- Getting started
- TypeScript in action
- Types
- Functions
- Configurating TypeScript compiler
- Classes & Interfaces
- Interfaces
- Advanced Types and TypeScript Features
- Generics
- Decorators
- Next steps
Getting started
TypeScript is an open-source language which builds on JavaScript. You can write JavaScript in TypeScript files, as TypeScript builds on top of the existing functionality of JavaScript. You cannot however write TypeScript inside a JavaScript (.js
) file. This is because TypeScript requires a compiler, which takes given TypeScript code and compiles it to JavaScript so that it can be run in the browser and elsewhere.
You can install TypeScript a number of ways. I installed it globally using:
npm install typescript -g
With TypeScript installed you can create TypeScript files as filename.ts
files and run tsc filename
to compile to JS locally.
TypeScript in action
TypeScript’s main use is to declare the type a given property may have when used. It can be done so by declaring it within a function definition, when declaring variables, or within classes. The types can apply to variables, functions, classes and can apply to input data or the expected output of a function.
To start simply, we can define the type
of given inputs to a function like so.
function add(num1: number, num2: number) {
...
}
This tells the compiler that num1
and num2
should be given a value of type number
. It will display an error (when editing, depending on editor, or during compile time) if we are passing in a value that is not a number.
Editors such as VSCode will show errors before compilation but you can also run tsc finename.ts
to generate the JavaScript and it will display any errors.
Types
Core data types:
- number
- string
- boolean
- object
- Arrays (string[], number[] etc)
Defining types in functions
function add(n1: number, n2: number, show: boolean, phrase: string) {
const result = n1 + n2;
if (show) {
console.log(`${phrase} ${result}`);
}
return result;
}
const number1 = 5;
const number2 = 2.8;
const printResult = true;
const resultPhrase = 'Result is: ';
add(number1, number2, printResult, resultPhrase);
Type inference
TypeScript infers types when they are initialised:
let number1 = 5; // type number automatically inferred
number1 = 'ok'; // error
If not specifying a value, you can specify it manually:
let number1: number;
number1 = 'ok'; // error
Using “!” or “as HTMLInputElement” (type casting)
When using HTML selectors you can use !
to say “this will not be null” or we can be more specific and say “as HTMLInputElement so that TypeScript knows that the resulting elements will have certain properties.
Object type
An inferred object will let the editor highlight issues with missing properties:
const person = {
name: 'Person',
age: 100
};
console.log(person.foo) // error
When defining an object type, an explicit declaration blocks TypeScript from analysing object further which could cause unexpected errors:
const person: object = {
name: 'Person',
age: 100
};
console.log(person.name) // error
Here name
is a property but TypeScript only considers person
an object
without delving deeper. Letting the object be inferred allows TypeScript to parse the properties.
const person = {
name: 'Person',
age: 100
};
console.log(person.name) // no error now
Custom object definition
We can explicitly define the object when setting it up:
const person: {
name: string;
age: number;
} = {
name: 'Person',
age: 100
};
While not something you’d do in practice (the types can be inferred).
Array types
An array containing strings is described as type string[]
.
TypeScript will infer this type if an object is given an array of strings. You can also explicity define it:
let myArray: string[];
Multiple types of items in the array can be specified, such as if it has strings or numbers:
let myArray: (string | number)[];
If any values allowed in the array:
let myArray: any[];
One side effect of knowing the type is that when looping through arrays, editors can suggest string methods automatically such as toUpperCase
.
for (const hobby of person.hobbies) {
console.log(hobby.toUpperCase()); // No error
console.log(hobby.map()); // Does not exist on type string
}
Methods unavailable to the type string
would then show an error when writing the code.
Other types: Tuples
An example of a tuple where we can specify the specific order of types in an array, for example:
const person = {
name: string;
age: number;
hobbies: string[];
role: [number, string]; // Specifies that the role array has a number then a string in 2 elements
};
By defining the role
array, it allows us to specify a specific array length and the types of data in it.
Note: array.push
is a special case though, and on a defined tuple array would not show in the editor as an array.
Other types: enum
A defined set of values that a variable can use. As a custom type, enum
s start with a capital letter. An example:
enum Role { ADMIN, READ_ONLY, AUTHOR };
The compiler then produces code in which the Role
array uses these keys and assigns numberic values to them. In TypeScript that can continue to be used like Role.ADMIN
so that the raw numberic value does not need to be managed.
You can assign specific values:
enum Role { ADMIN = 5, READ_ONLY, AUTHOR }; // Increments from 5
enum Role { ADMIN = 5, READ_ONLY = 100, AUTHOR = 200 }; // Specific numbering
enum Role { ADMIN = 'ADMIN', READ_ONLY, AUTHOR }; // Text alternative
Other types: any
any
is not prescriptive and allows a variable to contain anything:
const whatever: any;
It’s important to avoid this if possible as it removes all the benefits of using TypeScript such as checking for errors at writing / compile time.
It should only be used if you cannot tell before runtime what data might be in place, but should be otherwise avoided. Always try to use a built-in type or a custom type if more appropriate.
Other types: union
Accepting multiple types means joining them with the |
pipe symbol. These are union types:
let input1: number | string;
You might need to do runtime checks to avoid compiler errors by manually checking the type of value given and adjusting the result. For example, you might need to run toString
on a value that can be a number or a string and it’s not a number, which makes it more explicit to the compiler what you’re doing.
Other types: literal
If we want to make sure a given value is an expected value we can set it to a literal type, often used as a union type such as:
let val: 'thing-1' | 'thing-2';
This will then limit the potential values to those two strings and will show an error if it does match the one of the expected values.
Type aliases
We can build type aliases to make creating more complex types easier. For example:
type NumOrString = number | string;
type ThingTypes = 'thing-1' | 'thing-2';
type ObjType = { prop1: string; prop2: number, thing: ThingTypes };
let value: NumOrString;
let thing: ThingTypes;
const obj: ObjType = { prop1: 'Foo', prop2: 12, thing: 'thing-1' };
This can make types that are more complex and composable at the same time.
Functions
The return value of functions can be inferred by TypeScript. The following will infer that the expected result is of type number
.
function add(n1: number, n2: number) {
return n1 + n1;
}
We can manually specify a function’s return type by passing it with the function definition:
function add(n1: number, n2: number):string {
return n1 + n1; // Error, this will be a number
}
Return: void
If a function doesn’t return anything, TypeScript will report that the function returns void
.
This is not to be confused with type undefined
, which is a different and valid type in TypeScript. A function that doesn’t not return anything doesn’t return undefined
and should be instead be written:
function add(n1: number, n2: number):void {
console.log(n1 + n1); // No returning
}
However if the function does return
without returning a value, you could write:
function add(n1: number, n2: number):undefined {
console.log(n1 + n1);
return;
}
Other types: Function
Function types describe a function including the parameters and return values of the desired function.
We can delare the type Function
but that simply allows a variable to store any function at all.
let myAdd: Function;
We can be more specific by defining our own function type:
function add(n1: number, n2: number) {
return n1 + n2;
}
let myAdd: (a: number, b: number) => number;
myAdd = (a, b) => add(a, b);
console.log(myAdd(2, 2)); // Outputs 4
Other types: unknown
Unknown works a little like any
, except that once set, if you then apply a variable of type unknown
to a variable of another type it will error. It is only assignable to the any
or unknown
types of variables.
TypeScript will also error if we try to try to run arbitrary operations on variables of type unknown
:
let value: unknown;
value.trim();
In this way unknown
has some type checking whereas any
has none. It’s in some ways the opposite of any
- as the latter allows any operations whereas unknown
is more restrictive.
In general you can use unknown
when you do typeof
or instanceof
checks before carrying out operations on the variable.
Tip: Try to use more specific types when possible, but unknown
in preference of any
.
Other types: never
If a function does not produce a return
value of any sort, such as if a function throws an error, it is a function of type never
as it doesn’t even produce undefined
.
// Inferred function type: void
function throwCustomError(message: string, errorCode: number) {
throw {message, errorCode};
}
You can make it more explicit that the function never returns:
function throwCustomError(message: string, errorCode: number):never {
throw {message, errorCode};
}
Configurating TypeScript compiler
To run TypeScript locally, Node/NPM is required so that we can install it with:
npm i typescript -g
This allows us to run the command tsc myScript.ts
and it will compile the given script. However there are ways to do more than just run the compilation manually.
watch mode
The command tsc
can be run with --watch
or -w
to catch and re-compile then a target file changes.
This is handy for small use-cases. When working on an entire project we make use of configuration.
Watching a project
Set up a TypeScript project using tsc --init
to create a config file.
With this file in place we can run tsc -w
and it will then compile and watch any .ts
files in a project.
tsconfig.json
We use target
to specify which version of output JavaScript to generate. It defaults to ES5
.
lib
is used to specify which libraries are included in the global scope. By default it includes everything needed to run in the browser such as the DOM APIs, document
, console
etc. Uncommenting it removes these defaults.
We could specify this using "dom"
and "es6"
along with things like "dom.iterable"
and "scripthost"
.
allowJs
and checkJs
can be used to compile or check JavaScript files using the TypeScript compiler.
sourceMap
tells the compiler to build sourceMap files to help map compiled code back to the source to help in debugging.
rootDir
and outDir
help organising the files into source and output folders.
downlevelIteration
can help in specific cases where some kinds of for
loops cause issues in older browsers.
Adding options to tsconfig.json
An option worth considering is the noEmitOnError
setting. By default it is false
. This means that even when errors are encountered, the compiled JavaScript files are still generated.
If set to true, this will stop any files being written when errors are encountered.
We can also add extra options to the end of the generated config file, such as if we want to exclude certain files:
...
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
},
"exclude": [
"foo.ts",
"**/*.dev.ts",
"node_modules" // This is ignored by default if "exclude" not used
]
}
Alternately you can choose to only compile specific files or folders:
...
},
"include": [ "my_specific_file_only.ts", "./specialFolder/* ]
}
You can use files
to specify specific files only and not folders.
strict
options
Setting strict
to true sets all the following options to true.
This means applying rules such as not allowing implicit any
(where functions are given unspecified type variables).
strictNullChecks
makes TypeScript strict when checking the potential values of elements such as those from querySelector
. This can be overridden using !
like:
const button = document.querySelector("button")!;
The purpose is to avoid running into “cannot find property of null” error. Using the !
workaround is one approach but we could also avoid the error by adding an if
check around a situation that could potentially fail, as in:
if (button) {
button.addEventListener('click', () => {
...
});
}
strictFunctionTypes
checks any function on which you apply call
or bind
to change what is being passed in to the function. If bind
causes an issue, passing in the correct properties within bind
should work.
Additional checks
Turning on noUnusedLocals
, noUnusedParameters
are useful ways to highlight when unused local variables or parameters find their way into your code.
noImplicitReturns
will complain if we have functions that return in some places but not in others.
Full reference
More information on the options available can be found at the official TSConfig Reference documentation page.
Classes & Interfaces
When declaring classes we can specify the types for given properties, like:
class Example {
myProp: string; // Type of string expected
constructor(p: string) {
this.myProp = p; // Expects a string in p
}
logTheProp(this: Example) {
console.log(this.myProp)
}
}
const newThing = new Example('New thing');
Note that we define the given this
property in logTheProp
as being of type Example
. By doing so we ensure that calling the logTheProp
method out of the expected context will show up as an error when coding or compiling:
const randomOtherThing = { logTheProp: newThing.logTheProp };
randomOtherThing.logTheProp();
In the above we could fix the object by giving it a myProp
property like the Example
type.
public and private modifiers
Adding private
before the type definition in the class will ensure it cannot be accessed from outside the class.
class Example() {
private things: string[] = [];
addThing(thing: string) {
this.things.push(thing);
}
}
const newThing = new Example('New thing');
In this way the newThing.addThing
method is the only way to modify the things
array as it could otherwise be modified using newThing.things
directly.
Private
can also be applied to methods within a class and will be enforced by TypeScript.
Shorthand initialisation
We can use a shorthand to define properties with types in the constuctor:
class Example {
constructor(public myProp: string) {
}
logTheProp(this: Example) {
console.log(this.myProp)
}
}
const newThing = new Example('New thing');
This would work similarly to the first example above but save some code.
Read-only
Adding readonly
when defining class properties can prevent them being accidentally changed by other methods or from outside the class. For example:
class Example {
constructor(readonly myProp: string) {
}
changeThing() {
this.myProp = 'changed'; // Error
}
}
Inheritance
You can inherit and extend classes like so:
class SpecialExample extends Example {
// Use super in the inheriting class to call the base class constructor
constructor(p: string, public specialThings: string[]) {
super('Special ' + p);
}
}
const newThing = new SpecialExample('New thing', ['More things']);
console.log(newThing);
// logs {things: Array(0), myProp: "Special New thing", specialThings: Array(1)}
Overriding properties and protected
By changing the private
property on things
above to protected
, it can be overridden in the inherited class:
class SpecialExample extends Example {
constructor(p: string, public specialThings: string[]) {
super('Special ' + p);
}
logThings() {
console.log('Special things: ', this.things);
// This.things shows error if "private" in base class
// Change to "protected" for this to work
}
}
Getters and setters
In a class getters and setters are written like:
// Inside class
private thing: string;
private things: string[];
get getMyThing() {
return this.thing;
}
set setMyThing(value: string) {
this.things.push(value);
}
...
console.log(newThing.getMyThing) // Note no parens
This allows access to getting and setting private properties of a class.
Static properties and methods
We can access static methods without instantiating a new
version of a class but instead access them directly within the class definition.
class Example {
static myProp: string = 'A thing';
static staticMethod(p: string) {
console.log(p)
}
}
Example.staticMethod('hello');
console.log(Example.myProp)
Singletons
When following the singleton pattern, TypeScript makes the process easier by allowing you to specify a private
constructor.
Interfaces
An interface
describes the structure of an object and can be used as a type
definition to check that a type matches the expected pattern.
interface Person {
name: string;
age: number;
greet(phrase: string): void;
}
let user1: Person;
user1 = {
name: 'Bill',
age: 50,
greet(phrase: string) {
console.log(phrase + ' ' + this.name);
}
}
user1.greet('Hey I am');
While it’s similar to using a custom type
, interface
can only be used to describe an object where type
can be used for different structures such as unions etc.
type Person = { ... } // Similar result as above
The benefit is that interface
is generally clearer. Interfaces can be used to define the way classes are implemented:
class PersonClass implements Person {
...
}
This then enforces the above structure on the class. It would enforce a name
and age
property along with greet
method.
Readonly interface properties
Adding readonly
to the definition shows an error if we then try to change that property later.
interface Person {
readonly name: string;
age: number;
greet(phrase: string): void;
}
let user1: Person;
user1 = {
name: 'Bill',
age: 50,
greet(phrase: string) {
console.log(phrase + ' ' + this.name);
}
}
user1.name = 'Bob'; // Error: name is read-only
This will also then apply within any classes that implement an interface.
Note: You can not add public
or private
to interface properties.
Extending interfaces
We can compose interfaces using extends and even involve multiple interfaces:
interface Named {
readonly name: string;
}
interface Aged {
age: number;
}
interface Person extends Named, Aged {
greet(phrase: string): void;
}
let user1: Person;
user1 = {
name: 'Bill',
age: 50,
greet(phrase: string) {
console.log(phrase + ' ' + this.name);
}
}
user1.greet('Hey I am');
Interfaces as function types
As an alternative to defining custom types, you can define a custom function type within an interface:
interface AddFn {
(a: number, b: number): number
}
let add: AddFn;
add = (n1: number, n2: number) => {
return n1 + n2;
}
Optional properties
Using ?
defines any type as optional. This can also be handled by using a default value. In this example we add an optional nickname
property which will show if it is set:
interface Named {
readonly name: string;
nickname?: string;
}
interface Aged {
age: number;
}
interface Person extends Named, Aged {
greet(phrase: string): void;
}
let user1: Person;
let user2: Person;
user1 = {
name: 'Bill',
age: 50,
greet(phrase: string) {
console.log(phrase + ' ' + this.name);
}
}
user2 = {
name: 'Bob',
nickname: 'Dave',
age: 30,
greet(phrase: string) {
console.log(phrase + ' ' + (this.nickname || this.name));
}
}
user2.greet('Hey I am'); // Dave
Advanced Types and TypeScript Features
Intersection Types
We can define multiple types then compose them into intersection types.
type Admin = {
name: string;
privileges: string[];
};
type Employee = {
name: string;
startDate: Date; // a built-in interface
};
type ElevatedEmployee = Admin & Employee;
const e1: ElevatedEmployee = {
name: 'Jen',
privileges: ['admin'],
startDate: new Date()
};
If we were using interface
for the type definitions we could use extends
as in the previous section.
Types can cancel each other out when intersecting, so the following will resolve to simply be of type number
:
type a = string | number;
type b = number | boolean;
type Intersection = a & b; // Type: number
Type guards
In the following, TypeScript will complain as the a + b
part could be either a string or number for each input:
type myType = string | number;
function add(a: myType, b: myType) {
return a + b;
}
We add a type guard to show that we are taking this into consideration, removing the error:
type myType = string | number;
function add(a: myType, b: myType) {
if (typeof a === 'string' || typeof b === 'string') {
return a.toString() + b.toString();
}
return a + b;
}
Sometimes we might want to use an optional property like obj.propertyName
. This will error as we do not know whether the object will have that property.
We can also look for the existence of optional properties using in
, such as:
if ('propertyName' in obj) {
// ...act on it
}
For classes, the instanceof
type guard can be used to determine if a class has the expected method.
if (vehicle instanceof Truck) {
// ...
}
These guards allow for flexible type definitions while still ensuring we are confident that the code will be robust.
Discriminated Unions
When creating a union-based type we might want to have some way to determine which of the given types in the union are bring used. In the following example, the types Horse
and Bird
make use of the shared type
property that can be one of two values to determine which interface applies to the given object:
interface Bird {
type: 'bird';
flyingSpeed: number;
}
interface Horse {
type: 'horse';
runningSpeed: number;
}
type Animal = Bird | Horse;
function moveAnimal(animal: Animal) {
let speed;
switch (animal.type) {
case 'bird':
speed = animal.flyingSpeed;
break;
case 'horse':
speed = animal.runningSpeed;
}
console.log('Moving at speed: ' + speed);
}
moveAnimal({ type: 'bird', flyingSpeed: 100 });
moveAnimal({ type: 'horse', runningSpeed: 30 });
This gives us a safe way to ensure that we don’t accidentally use the incorrect object definition and avoids the previous type guards using typeof
or checking for specific properties in the object.
Type Casting
We can manually direct TypeScript to expect certain values to be of a specific type as it may not always be able to infer the type.
For example a document selector will be considered of type HTMLElement
or null
, and so TypeScript will complain if we try to access a specific property of an input
type for example:
const inputEl = document.getElementById('user-input');
inputEl.value = 'Hello'; // inputEl could be null
If we avoid the null
type by adding !
to the query we get a different error:
const inputEl = document.getElementById('user-input')!;
inputEl.value = 'Hello'; // value not a known property on HTMLElement
So instead we can cast the type <HTMLInputElement>
directly on the query. This tells TypeScript that the result of the getElementById
will be of type HTMLInputElement
and not null
or HTMLElement
:
const inputEl = <HTMLInputElement>document.getElementById('user-input');
inputEl.value = 'Hello'; // No error
Alternatively you can avoid clashes with JSX formatting by using as
:
const inputEl = document.getElementById('user-input') as HTMLInputElement;
inputEl.value = 'Hello';
Note that the above casting implicitly removes the null
type as an option so we might want to instead check that manually if we are not sure whether it might be null.
Index types
When defining a custom type interfaces we can use index
types to set out a shape for the expected object.
interface ErrorContainer {
[prop: string]: string;
}
const errors: ErrorContainer = {
email: 'The key is a string here',
100: 'A number can also converted to a string'
};
The above says that an object of type ErrorContainer
can have any number of keys but the key should be a string (or be able to be converted to a string) and have a string as the value for each.
Function Overloads
Function overloads are useful when you want to avoid issues where trying to run methods on results of functions where the result could be moe than one type. For example in the add function, this results in an output that could either be number
or string
so errors if we apply a string method:
function add(a: myType, b: myType) {
if (typeof a === 'string' || typeof b === 'string') {
return a.toString() + b.toString();
}
return a + b;
}
const result = add('String', 'Values'); // Always a string
result.split(' '); // Error as it still thinks it could be number
We can type case using add('String', 'Values') as String;
or overload the function:
// overloads for add function
function add(a: number, b: number): number;
function add(a: string, b: string | number): string;
function add(a: string | number, b: string): string;
function add(a: myType, b: myType) {
if (typeof a === 'string' || typeof b === 'string') {
return a.toString() + b.toString();
}
return a + b;
}
const result = add('String', 'Values') as String;
result.split(' ')
This documents the possible combinations of types that can be given to a function and specifies the expected return type for each.
Optional chaining
TypeScript includes built-in support for adding ?
to objects and their properties if they may be missing.
myObject?.property?.foo
Nullish Coalescing
Using ??
instead of ||
when determining if a variable is null or undefined will ensure that situations such as empty strings or zero values aren’t considered null.
const emptyVar = '';
const newThing = emptyVar || 'DEFAULT'; // Will consider empty string as falsey
const newThing2 = emptyVar ?? 'DEFAULT'; // Will allow empty string
console.log({newThing, newThing2})
Generics
In TypeScript, some built-in types are generic such as the Array
type, as they describe a structure within which it expects a certain type.
const myArray: Array = [1, 2, 3]; // Error
const myArray: Array<number> = [1, 2, 3]; // No error
By defining the type of items in the array, TypeScript then knows which methods are available on the given data, such as toFixed
or used within Math
methods. It could then error if a string
type of array used those features.
A similar example is Promise<string>
.
Using generic types
If we have the following function, we see an error when accessing a property on the merged objects:
function merge(objA: object, objB: object) {
return Object.assign(objA, objB);
}
const merged = merge({ name: 'Marge' }, { age: 30 });
console.log(merged.name);
TypeScript doesn’t know that the vaguely inferred object
output of the above function will contain name
.
We can fix this by defining the two given objects as generic
types so that TypeScript can infer that the output is merge
is in fact the combination of two generic input objects:
function merge<T, U>(objA: T, objB: U) {
return Object.assign(objA, objB);
}
const merged = merge({ name: 'Marge' }, { age: 30 });
console.log(merged.name);
We use T
and U
as symbols for these generic types as a convention but they could be any string.
TypeScript will infer the more specific types within these generic types based on how they are used.
Constraints
In the above generic example, the types T
and U
can be anything at all. While this works, it would be better to constrain them to ensure they are objects.
We set constraints using extends
within the <>
part of the function definition.
function merge<T extends object, U extends object>(objA: T, objB: U) {
return Object.assign(objA, objB);
}
const merged = merge({ name: 'Marge' }, { age: 30 });
console.log(merged.name);
This ensures that TypeScript will expect an object for each of the generic types, while also ensuring that the function outputs the merged objects T
& U
, which allows us to then confidently access properties such as name
.
Another example of a generic T
with a custom defined type (interface) as a constraint:
interface Lengthy {
length: number;
}
function countAndDescribe<T extends Lengthy>(element: T): [T, string] {
let desc = 'Got no elements';
if (element.length > 0) {
desc = `Got ${element.length} element${element.length === 1 ? '' : 's'}`;
}
return [element, desc];
}
console.log(countAndDescribe(['one', 'two']))
keyof
constraint
Extending using keyof
can be used to describe a type that represents a key of another type:
function extractAndConvert<T extends object, U extends keyof T>(obj: T, key: U) {
return obj[key];
}
console.log(extractAndConvert({ name: 'Max' }, 'name'));
Generic classes
Flexible reusable classes with strong typing can be created using generic types. For example:
class DataStorage<T extends string | number | boolean> {
private data: T[] = [];
addItem(item: T) {
this.data.push(item);
}
removeItem(item: T) {
this.data.splice(this.data.indexOf(item), 1);
}
getItems() {
return [...this.data];
}
}
const textStorage = new DataStorage<string>();
textStorage.addItem('TypeScript using generic classes');
textStorage.addItem('Remove me');
textStorage.removeItem('Remove me');
console.log(textStorage.getItems());
const numberStorage = new DataStorage<number>();
numberStorage.addItem(2);
numberStorage.addItem(3);
numberStorage.removeItem(3);
console.log(numberStorage.getItems());
The above works well enough with primatives as it relies on indexOf
. To protect it we specify string | number | boolean
as it would need a different approach for objects.
In the above, more than one generic can be used if needed, along with constraints.
Generic utility types
Some generic utility types are available including:
Partial
wraps a type to make any properties on it temporarily optional.
interface Thing {
title: string;
description: string
}
function createThing(title: string, description: string): Thing {
let thing: Partial<Thing> = {};
thing.title = title;
thing.description = description;
return thing as Thing;
}
Required
does the opposite.
ReadOnly
can be used on arrays or objects to stop new items being added or properties being added to an object:
const names: Readonly<string[]> = ['Superman'];
names.push('Clark Kent'); // Error as it is read only
Find more generic utility types here.
Decorators
Decorators are useful for meta-programming. By that, it makes it easier to write code that is easier for other developers to work with.
We can create and apply a simple decorator like so:
function Logger(constructor: Function) { // Convention: capital letter
console.log('Logging...');
console.log(constructor);
}
@Logger
class Person {
name = 'Jo';
constructor() {
console.log('Creating person object');
}
}
const pers = new Person();
console.log(pers);
Decorator factory
A decorator can be run ()
to then return a function.
function Logger(logString: string) { // Convention: capital letter
return (constructor: Function) => {
console.log(logString);
console.log(constructor);
}
}
@Logger('Logging - Person')
class Person {
name = 'Jo';
constructor() {
console.log('Creating person object');
}
}
const pers = new Person();
console.log(pers);
Another example to handle more complex logic and wrap it up into a decorator that offers specific functionality:
function WithTemplate(template: string, hookId: string) {
return (_: Function) => {
const element = document.getElementById(hookId);
if (element) {
element.innerHTML = template;
}
};
}
@WithTemplate('<h1>Hello World</h1>', 'app')
class Person {
name = 'Jo';
constructor() {
console.log('Creating person object');
}
}
This could then be adjusted to make use of the given constructor from the class being decorated:
function WithTemplate(template: string, hookId: string) {
return (constructor: any) => {
const element = document.getElementById(hookId);
const p = new constructor(); // Constructor from Person
if (element) {
element.innerHTML = template;
element.querySelector('h1')!.textContent = p.name;
}
};
}
@WithTemplate('<h1>Fallback</h1>', 'app')
class Person {
name = 'Jo';
constructor() {
console.log('Creating person object');
}
}
Adding multiple decorators
When running multiple decorators, they execute from the bottom up:
@Decorator1
@Decorator2
class MyClass...
In the above, the second Decorator2
executes first. However if they are factories, they execute top to bottom first, then their returned function is executed from last to first.
Accessor, method and property decorators
When using decorators in other parts of code, they receive different arguments.
If used on a property, it receives target
and propertyName
:
function Log(target: any, propertyName: string) {
console.log('Property decorator');
console.log(target, propertyName);
}
class Product {
@Log
title: string;
private _price: number;
}
The property decorator is executed when the class definition is registered with JavaScript, before being instantiated.
Accessor and Parameter decorators
Applying a decorator to accessors (setters / getters) gives it a name and descriptor as so:
function LogAccessor(target: any, name: string, descriptor: PropertyDescriptor) {
console.log('Accessor decorator');
console.log(target);
console.log(name);
console.log(descriptor);
}
class Product {
title: string;
private _price: number;
constructor(t: string, p: number) {
this.title = t;
this._price = p;
}
@LogAccessor
set price(val: number) {
if (val > 0) {
this._price = val;
} else {
throw new Error('Invalid price - should be positive')
}
}
getPriceWithTax(tax: number) {
return this._price * tax;
}
}
A method decorator is similar:
function LogMethod(target: any, name: string | Symbol, descriptor: PropertyDescriptor) {
console.log('Method decorator');
console.log(target);
console.log(name);
console.log(descriptor);
}
class Product {
title: string;
private _price: number;
constructor(t: string, p: number) {
this.title = t;
this._price = p;
}
set price(val: number) {
if (val > 0) {
this._price = val;
} else {
throw new Error('Invalid price - should be positive')
}
}
@LogMethod
getPriceWithTax(tax: number) {
return this._price * tax;
}
}
Logging a parameter is similar but instead of descriptor, provides a position
argument containing a zero-indexed position for the parameter.
function LogParameter(target: any, name: string | Symbol, position: number) {
console.log('Parameter decorator');
console.log(target);
console.log(name);
console.log(position);
}
class Product {
title: string;
private _price: number;
constructor(t: string, p: number) {
this.title = t;
this._price = p;
}
set price(val: number) {
if (val > 0) {
this._price = val;
} else {
throw new Error('Invalid price - should be positive')
}
}
getPriceWithTax(@LogParameter tax: number) {
return this._price * tax;
}
}
Adding custom class logic to decorators
The following adjusts the previous decorator to make it only run when the class is instantiated, rather than when the class is defined:
function Logger(logString: string) { // Convention: capital letter
return (_: Function) => {
console.log(logString);
};
}
function WithTemplate(template: string, hookId: string) {
return <T extends {new(...args: any[]): { name: string }}>(orig_constructor: T) => {
console.log('Rendering template');
// The following class is only run when the class is instantiated
// Rather than when the class is defined
return class extends orig_constructor {
constructor(..._: any[]) {
super(); // Call the base constructor
const element = document.getElementById(hookId);
if (element) {
element.innerHTML = template;
element.querySelector('h1')!.textContent = this.name;
}
}
}
};
}
@Logger('Instantiating create person object')
@WithTemplate('<h1>Fallback</h1>', 'app')
class Person {
name = 'Jo';
constructor() {
console.log('Creating person object');
}
}
// Activate the custom class that replaces the given class above
const pers = new Person();
console.log(pers);
Returning from decorators
When returning values from decorators, you can return property descriptors.
We can return these from method and parameter decorators. This allows us to override or set our own getter
and setter
descriptors for example, along with enumarable
or configurable
properties.
Decorator example - autobind
function Autobind(_: any, _2: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
return {
configurable: true,
enumerable: false,
get() {
return originalMethod.bind(this);
}
}
}
class PrintMessage {
message: string = 'This works!';
@Autobind
showMessage() {
console.log(this.message);
}
}
const button = document.getElementById('mybutton')!;
const p = new PrintMessage();
button.addEventListener('click', p.showMessage);
Other notes
You can use _
in argument lists to mark an expected argument that will not be used:
function(_: string, _2: number) {
// _ or _2 not used
}
Next steps
The TypeScript website is a good starting point to explore TypeScript further.
Well that’s enough about me. Your turn!
Have you build a cool Svelte app you’d like to tell me about? You can message me on Mastodon, I’d love to hear from you.