Objects and Data Structures
Use getters and setters
TypeScript supports getter/setter syntax. Using getters and setters to access data from objects that encapsulate behavior could be better than simply looking for a property on an object. "Why?" you might ask. Well, here's a list of reasons:
- When you want to do more beyond getting an object property, you don't have to look up and change every accessor in your codebase.
- Makes adding validation simple when doing a
set
. - Encapsulates the internal representation.
- Easy to add logging and error handling when getting and setting.
- You can lazy load your object's properties, let's say getting it from a server.
Bad:
type BankAccount = {
balance: number;
// ...
}
const value = 100;
const account: BankAccount = {
balance: 0,
// ...
};
if (value < 0) {
throw new Error('Cannot set negative balance.');
}
account.balance = value;
Good:
class BankAccount {
private accountBalance: number = 0;
get balance(): number {
return this.accountBalance;
}
set balance(value: number) {
if (value < 0) {
throw new Error('Cannot set negative balance.');
}
this.accountBalance = value;
}
// ...
}
// Now `BankAccount` encapsulates the validation logic.
// If one day the specifications change, and we need extra validation rule,
// we would have to alter only the `setter` implementation,
// leaving all dependent code unchanged.
const account = new BankAccount();
account.balance = 100;
Make objects have private/protected members
TypeScript supports public
(default), protected
and private
accessors on class members.
Bad:
class Circle {
radius: number;
constructor(radius: number) {
this.radius = radius;
}
perimeter() {
return 2 * Math.PI * this.radius;
}
surface() {
return Math.PI * this.radius * this.radius;
}
}
Good:
class Circle {
constructor(private readonly radius: number) {
}
perimeter() {
return 2 * Math.PI * this.radius;
}
surface() {
return Math.PI * this.radius * this.radius;
}
}
Prefer immutability
TypeScript's type system allows you to mark individual properties on an interface/class as readonly. This allows you to work in a functional way (an unexpected mutation is bad).
For more advanced scenarios there is a built-in type Readonly
that takes a type T
and marks all of its properties as readonly using mapped types (see mapped types).
Bad:
interface Config {
host: string;
port: string;
db: string;
}
Good:
interface Config {
readonly host: string;
readonly port: string;
readonly db: string;
}
For arrays, you can create a read-only array by using ReadonlyArray<T>
.
It doesn't allow changes such as push()
and fill()
, but can use features such as concat()
and slice()
that do not change the array's value.
Bad:
const array: number[] = [ 1, 3, 5 ];
array = []; // error
array.push(100); // array will be updated
Good:
const array: ReadonlyArray<number> = [ 1, 3, 5 ];
array = []; // error
array.push(100); // error
Declaring read-only arguments in TypeScript 3.4 is a bit easier.
function hoge(args: readonly string[]) {
args.push(1); // error
}
Prefer const assertions for literal values.
Bad:
const config = {
hello: 'world'
};
config.hello = 'world'; // value is changed
const array = [ 1, 3, 5 ];
array[0] = 10; // value is changed
// writable objects is returned
function readonlyData(value: number) {
return { value };
}
const result = readonlyData(100);
result.value = 200; // value is changed
Good:
// read-only object
const config = {
hello: 'world'
} as const;
config.hello = 'world'; // error
// read-only array
const array = [ 1, 3, 5 ] as const;
array[0] = 10; // error
// You can return read-only objects
function readonlyData(value: number) {
return { value } as const;
}
const result = readonlyData(100);
result.value = 200; // error
type vs. interface
Use type when you might need a union or intersection. Use an interface when you want extends
or implements
. There is no strict rule, however, use the one that works for you.
For a more detailed explanation refer to this answer about the differences between type
and interface
in TypeScript.
Bad:
interface EmailConfig {
// ...
}
interface DbConfig {
// ...
}
interface Config {
// ...
}
//...
type Shape = {
// ...
}
Good:
type EmailConfig = {
// ...
}
type DbConfig = {
// ...
}
type Config = EmailConfig | DbConfig;
// ...
interface Shape {
// ...
}
class Circle implements Shape {
// ...
}
class Square implements Shape {
// ...
}