banner
 Sayyiku

Sayyiku

Chaos is a ladder
telegram
twitter

TypeScript Coding Standards

TypeScript is a free and open-source programming language developed by Microsoft. It is a superset of JavaScript and essentially adds optional static typing and class-based object-oriented programming to the language.

Naming#

  1. Use PascalCase for naming types, including interfaces, type aliases, and classes.
  2. Do not use I as a prefix for interface names; interface members should be named using camelCase.
// Bad
interface IFoo {
  Bar: number
  Baz(): number
}

// Good
interface Foo {
  bar: number
  baz(): number
}

Why not use the I prefix for naming interfaces?

  1. The I prefix violates the encapsulation principle: In TypeScript, classes can implement interfaces, interfaces can inherit from interfaces, and interfaces can inherit from classes. Both classes and interfaces are abstractions and encapsulations in some sense, and when inheriting, it is unnecessary to care whether it is an interface or a class. If the I prefix is used, when a variable's type changes, such as from an interface to a class, the variable name must also be changed accordingly.
  2. Prevent inappropriate naming: Prohibiting the use of the I prefix forces programmers to give interfaces suitable, semantically meaningful names that distinguish them from other similar variables, rather than just using a prefix.
  3. The era of Hungarian notation is outdated: Hungarian notation consists of a type prefix followed by the actual variable name. With this method, seeing the variable name immediately reveals its type. However, its disadvantages far outweigh its benefits, such as making variable names lengthy and creating ambiguity for variables with the same base name but different types.

Example:

interface IFoo {}
class Point {}
type Baz = IFoo & Point

What we care about is whether this is a "type," regardless of whether it is an interface, class, or type; they are all treated as "types," and there is no need to add a prefix to the interface to distinguish it.

  1. Prohibition against prefixing interfaces with "I"
  2. Confused about the Interface and Class coding guidelines for TypeScript
  1. Use PascalCase for naming enum objects and enum members.
// Bad
enum color {
  red,
}

// Good
enum Color {
  Red,
}
  1. Use camelCase for naming functions.
  2. Use camelCase for naming properties or local variables.
// Bad
const DiskInfo
function GetDiskInfo() {}

// Good
const diskInfo
function getDiskInfo() {}
  1. Use PascalCase for naming classes, and class members should be named using camelCase.
// Bad
class foo {}

// Good
class Foo {}

// Bad
class Foo {
  Bar: number
  Baz(): number {}
}

// Good
class Foo {
  bar: number
  baz(): number {}
}
  1. Use camelCase for naming the namespace when importing modules, and use snake_case for file names.
import * as fooBar from './foo_bar'
  1. Do not add a _ prefix to private property names.
  2. Use complete word spellings for naming whenever possible.

Summary:

Naming ConventionCategory
PascalCaseClasses, Interfaces, Types, Enums, Enum Values, Type Parameters
camelCaseVariables, Parameters, Functions, Methods, Properties, Module Aliases
CONSTANT_CASEGlobal Constants

Modules#

Importing#

TypeScript code must use paths for imports. The paths can either be relative paths starting with . or .., or absolute paths starting from the project root, such as root/path/to/file.

When referencing files that logically belong to the same project, use relative paths like ./foo, and avoid using absolute paths like path/to/foo.

Limit the number of parent levels as much as possible (avoid paths like ../../../), as excessive levels can make the module and path structure difficult to understand.

import { Symbol1 } from 'google3/path/from/root'
import { Symbol2 } from '../parent/file'
import { Symbol3 } from './sibling'

In ES6 and TypeScript, there are four variants of import statements:

Import TypeExamplePurpose
Moduleimport * as foo from '...'TypeScript import style
Destructuringimport { SomeThing } from '...'TypeScript import style
Defaultimport SomeThing from '...'Only for special needs of external code
Side Effectsimport '...'Only for loading side effects of certain libraries (e.g., custom elements)
// Do this! Choose the more appropriate one from these two variants (see below).
import * as ng from '@angular/core'
import { Foo } from './foo'

// Only use default imports when necessary.
import Button from 'Button'

// Sometimes, importing certain libraries is for their code execution side effects.
import 'jasmine'
import '@polymer/paper-button'

Depending on the usage scenario, module imports and destructuring imports each have their own advantages.

Module imports:

  1. The module import statement provides a name for the entire module, allowing all symbols in the module to be accessed through this name, which improves code readability and allows for autocompletion of all symbols in the module.
  2. Module imports reduce the number of import statements, lowering the chances of naming conflicts, and allow for providing a concise name for the imported module.

Destructuring import statements provide a local name for each imported symbol, making the code cleaner when using the imported symbols.

In the code, you can use renamed imports to resolve naming conflicts:

import { SomeThing as SomeOtherThing } from './foo'

Renamed imports may be useful in the following situations:

  1. To avoid naming conflicts with other imported symbols.
  2. When the name of the imported symbol is auto-generated.
  3. When the name of the imported symbol does not clearly describe itself and needs to be renamed for better code readability, such as renaming RxJS's from function to observableFrom.

Exporting#

Code must use named export declarations. Do not use default exports, as this ensures that all import statements follow a consistent pattern.

// Use named exports:
export class Foo {}

// X Do not do this! Do not use default exports!
export default class Foo {}

Why? Because default exports do not provide a standard name for the exported symbol, increasing maintenance difficulty and reducing readability risks, while not providing significant benefits.

// Default exports can cause the following issues
import Foo from './bar' // This statement is valid.
import Bar from './bar' // This statement is also valid.

One advantage of named exports is that when the code tries to import a symbol that has not been exported, it will throw an error. For example, suppose there is the following export declaration in foo.ts:

// Do not do this!
const foo = 'blah'
export default foo

If there is the following import statement in bar.ts:

// Compilation error!
import { fizz } from './foo'

It will result in a compilation error: error TS2614: Module '"./foo"' has no exported member 'fizz'. Conversely, if the import statement in bar.ts is:

// Do not do this! This defines an unnecessary variable fizz!
import fizz from './foo'

The result is fizz === foo, which often does not meet expectations and is difficult to debug.

Types#

Declaration Standards#

  1. Do not export unless the type/function needs to be shared across multiple components.
  2. Type definitions should be placed at the top of the file.

Automatic Type Inference#

When declaring types, rely on TypeScript's automatic type inference as much as possible; if the correct type can be inferred, avoid manual declarations.

  1. Basic type variables do not need to have their types manually declared.
let foo = 'foo'
let bar = 2
let baz = false
  1. Reference type variables should ensure type correctness; incorrect types need to be manually declared.
// Automatic inference
let foo = [1, 2] // number[]

// Explicit declaration
// Bad
let bar = [] // any[]

// Good
let bar: number[] = []

Boxed Types#

Under no circumstances should these boxed types be used. Do not use types like Number, String, Boolean, Object; these types refer to boxed types, while the types number, string, boolean, object refer to unboxed types.

// Bad
function reverse(s: String): String

// Good
function reverse(s: string): string

For example, String includes undefined, null, void, and the unboxed type string, but does not include the unboxed types corresponding to other boxed types. Consider the following code:

// The following code is valid
const tmp1: String = undefined
const tmp2: String = null
const tmp3: String = void 0
const tmp4: String = 'linbudu'

// The following code is invalid because it is not an unboxed string type
const tmp5: String = 599
const tmp6: String = { name: 'linbudu' }
const tmp7: String = () => {}
const tmp8: String = []

null or undefined?#

In TypeScript code, either undefined or null can be used to indicate a missing value; there is no universal rule dictating which one should be used. Many JavaScript APIs use undefined (e.g., Map.get), while the DOM tends to use null (e.g., Element.getAttribute), so the choice between null and undefined depends on the current context.

  1. Nullable/undefined type aliases

Do not create type aliases that include |null or |undefined in union types. Such nullable aliases typically imply that null values will be passed around in the application, obscuring the source of the null values. Additionally, such aliases make it uncertain when a value in a class or interface might be null.

Therefore, code must only allow adding |null or |undefined when using the alias. Furthermore, code should handle null values near where they occur.

// Do not do this! Do not include undefined when creating aliases!
type CoffeeResponse = Latte | Americano | undefined

class CoffeeService {
  getLatte(): CoffeeResponse {}
}

The correct approach:

// Do this! Union with undefined when using aliases!
type CoffeeResponse = Latte | Americano

class CoffeeService {
  // Code should handle null values near where they occur
  getLatte(): CoffeeResponse | undefined {}
}
  1. Prefer optional parameters/optional fields

TypeScript supports creating optional parameters and optional fields, for example:

interface CoffeeOrder {
  sugarCubes: number
  milk?: Whole | LowFat | HalfHalf
}

function pourCoffee(volume?: Milliliter) {}

Optional parameters implicitly union |undefined into the type. Prefer using optional fields (for classes or interfaces) and optional parameters over union |undefined types.

interface or type?#

  1. interface: Interfaces are designed in TypeScript to define object types and can describe the shape of an object.
  2. type: Type aliases are used to define aliases for various types; they are not a type themselves, just an alias.

Similarities:

  1. Both can describe an object or function.
// interface
interface User {
  name: string
  age: number
}

interface SetUser {
  (name: string, age: number): void
}

// type
type User = {
  name: string
  age: number
}

type SetUser = (name: string, age: number) => void
  1. Both allow inheritance

Both interface and type can inherit, and they are not mutually exclusive; that is, an interface can extend a type, and a type can also extend an interface. Although the effects are similar, the syntax differs.

// interface extends interface
interface Name {
  name: string
}
interface User extends Name {
  age: number
}

// type extends type
type Name = {
  name: string
}
type User = Name & { age: number }

// interface extends type
type Name = {
  name: string
}
interface User extends Name {
  age: number
}

// type extends interface
interface Name {
  name: string
}
type User = Name & {
  age: number
}

Differences:

  1. Type can declare basic type aliases, union types, intersection types, tuples, etc., while interface cannot.
// Basic type alias
type Name = string

// Union type
interface Dog {
  wong()
}
interface Cat {
  miao()
}
type Pet = Dog | Cat

// Tuple type, specifically defining the type of each position in the array
type PetList = [Dog, Pet]
  1. The type statement can also use typeof to get the type of an instance for assignment.
// When you want to get the type of a variable, use typeof
const div = document.createElement('div')
type B = typeof div
  1. Interfaces can declare merging, while repeating declarations of types will result in an error.
interface User {
  name: string
  age: number
}

interface User {
  sex: string
}

/*
User interface is {
  name: string
  age: number
  sex: string
}
*/

Summary:

  • Use type aliases when using union types, intersection types, tuples, etc.
  • Use interface when needing to use extends for type inheritance.
  • For other type definitions, prefer using interface.

Therefore, when declaring types for objects, use interfaces rather than type aliases for object literal expressions:

// Do this!
interface User {
  firstName: string
  lastName: string
}

// Do not do this!
type User = {
  firstName: string
  lastName: string
}

Why? The two forms are nearly equivalent, so based on the principle of choosing one of the two forms to avoid variations in the project, the more common interface form is chosen. Relevant technical reasons TypeScript: Prefer Interfaces .

The words of the TypeScript team leader: "Honestly, my personal opinion is that interfaces should be used for any modelable object. In contrast, using type aliases offers no advantages, especially since type aliases have many visibility and performance issues."

Bypassing Type Checking#

  1. Duck Typing

If you see a bird that walks like a duck, swims like a duck, and quacks like a duck, then this bird can be called a duck.

Duck typing in TypeScript means that we can build methods like walk, swim, quack on a bird to create a bird that looks like a duck, thereby bypassing type checking for ducks.

interface Param {
  field1: string
}

const func = (param: Param) => param
func({ field1: '111', field2: 2 }) // Error

const param1 = { field1: '111', field2: 2 }
func(param1) // success

Here, a function func is constructed to accept a parameter of type Param. When calling func directly with parameters, it is strictly checked against the parameter, resulting in an error.

However, if a temporary variable is used to store the value and then passed to func, the duck typing feature applies because param1 contains field1, and TypeScript considers param1 to have fully implemented Param, allowing it to be treated as a subclass of Param, thus bypassing the check for the extra field2.

  1. Type Assertion
interface Param {
  field1: string
}

const func = (param: Param) => param
func({ field1: '111', field2: 2 } as Param) // success

any Type#

The any type in TypeScript is the supertype of all other types and also the subtype of all other types, allowing dereferencing of any properties. Therefore, using any is very dangerous; it can mask serious program errors and fundamentally undermines the principle that values "have static properties."

Avoid using any as much as possible. If a situation arises where any is needed, consider the following solutions:

  1. Narrow the scope of any
function f1() {
  const x: any = expressionReturningFoo() // Not recommended, subsequent x is all any
  processBar(x)
}

function f2() {
  const x = expressionReturningFoo()
  processBar(x as any) // Recommended, only here is any
}
  1. Use more specific any
const numArgsBad = (...args: any) => args.length // Return any not recommended
const numArgs = (...args: any[]) => args.length // Return number recommended
  1. Automatic inference of any

The any type in TypeScript is not static; it can change based on user operations, and TypeScript will guess a more reasonable type.

const output = [] // any[]
output.push(1) // number[]
output.push('2') // (number|string)[]
  1. Prefer using unknown over any

The any type can be assigned to any other type and can dereference any properties. Generally, this behavior is unnecessary and unexpected; in such cases, the code is trying to express that "the type is unknown." In this case, the built-in unknown type should be used. It can express the same semantics, and because unknown cannot dereference any properties, it is safer than any. A variable of type unknown can be reassigned to any other type.

Type Assertion#

  1. Use type assertions and non-null assertions cautiously

Type assertions (x as SomeType) and non-null assertions (y!) are unsafe. These two syntaxes can only bypass the compiler and do not add any runtime assertion checks, which may cause the program to crash at runtime. Therefore, do not use type assertions and non-null assertions unless there is a clear or definite reason.

// Do not do this!
;(x as Foo).foo()

y!.bar()

If you want to assert types and non-null conditions, the best practice is to explicitly write runtime checks.

// Do this! 
// Here we assume Foo is a class.
if (x instanceof Foo) {
  x.foo()
}

if (y) {
  y.bar()
}

Sometimes, based on the context in the code, it can be determined that a certain assertion is necessarily safe. In such cases, comments should be added to explain why this unsafe behavior can be accepted; if the reason for using the assertion is clear, comments are not necessary.

// This can be done! 
// x is an instance of type Foo because…
;(x as Foo).foo()

// y cannot be null because…
y!.bar()
  1. Type assertions must use the as syntax; do not use angle bracket syntax, as this forces the use of parentheses outside the assertion.
// Do not do this!
const x = (<Foo>z).length
const y = <Foo>z.length

// Do this!
const x = (z as Foo).length
  1. Use type annotations (: Foo) instead of type assertions (as Foo) to indicate the type of object literals. This helps programmers discover bugs when modifying the field types of interfaces in the future.
interface Foo {
  bar: number
  baz?: string // This field was previously named "bam" and was later renamed to "baz".
}

const a: Foo = {
  bar: 123,
  bam: 'abc', // If using type annotations, this will throw an error after renaming!
}

const b = {
  bar: 123,
  bam: 'abc', // If using type assertions, this will not throw an error after renaming!
} as Foo

Enums#

Use enums instead of objects to set constant collections. Using ordinary constant collections defined by objects does not prompt errors during modification unless the as const modifier is used.

// Bad
const Status = {
  Success: 'success',
}

// Good
enum Status {
  Success = 'success',
}

You can also declare constant enums with const enum:

const enum Status {
  Success = 'success',
}

The main differences between constant enums and regular enums are accessibility and compilation output. For constant enums, you can only access enum values through enum members (not by value). Additionally, there will not be an extra helper object in the compilation output; access to enum members will be directly inlined to the enum values.

For enum types, you must use the enum keyword, but do not use const enum (constant enums). TypeScript's enum types are inherently immutable.

Extension: The as const modifier, when applied to variable declarations or the types of expressions, forces TypeScript to treat the type of the variable or expression as immutable. This means that if you try to modify the variable or expression, TypeScript will throw an error.

const foo = ['a', 'b'] as const
foo.push('c') // Error, because foo is declared as immutable

const bar = { x: 1, y: 2 } as const
bar.x = 3 // Error, because bar is declared as immutable

Arrays#

  • For simple types, use the array shorthand T[]
  • For other complex types, use the longer Array<T>

This rule also applies to readonly T[] and ReadonlyArray<T>.

// Do this!
const a: string[]
const b: readonly string[]
const c: ns.MyObj[]
const d: Array<string | number>
const e: ReadonlyArray<string | number>

// Do not do this!
const f: Array<string> // The shorthand is shorter
const g: ReadonlyArray<string>
const h: { n: number; s: string }[] // Braces and brackets make this line difficult to read
const i: (string | number)[]
const j: readonly (string | number)[]

Functions#

  1. Do not set a callback function with an any return type for ignored return values; use void:
// Bad
function fn(x: () => any) {
  x()
}

// Good
function fn(x: () => void) {
  x()
}

Using void is relatively safe because it prevents you from accidentally using x's return value:

function fn(x: () => void) {
  const k = x() // oops! meant to do something else
  k.doSomething() // error, but would be OK if the return type had been 'any'
}
  1. Function overloads should be ordered, with the specific ones placed before the ambiguous ones, because TypeScript will choose the first matching overload; if the overloads in front are more "ambiguous" than those behind, the latter will be hidden and not selected:
// Bad
declare function fn(x: any): any
declare function fn(x: HTMLElement): number
declare function fn(x: HTMLDivElement): string

let myElem: HTMLDivElement
let x = fn(myElem) // x: any, wat?

// Good
declare function fn(x: HTMLDivElement): string
declare function fn(x: HTMLElement): number
declare function fn(x: any): any

let myElem: HTMLDivElement
let x = fn(myElem) // x: string, :)
  1. Prefer using optional parameters over overloads:
// Bad
interface Example {
  diff(one: string): number
  diff(one: string, two: string): number
  diff(one: string, two: string, three: boolean): number
}

// Good
interface Example {
  diff(one: string, two?: string, three?: boolean): number
}
  1. Use union types; do not define overloads for parameters that differ only in one position:
// Bad
interface Moment {
  utcOffset(): number
  utcOffset(b: number): Moment
  utcOffset(b: string): Moment
}

// Good
interface Moment {
  utcOffset(): number
  utcOffset(b: number | string): Moment
}

Classes#

  1. Do not use #private syntax

Do not use #private private field (also known as private identifier) syntax to declare private members. Instead, use TypeScript's access modifiers.

// Do not do this!
class Clazz {
  #ident = 1
}

// Do this!
class Clazz {
  private ident = 1
}

Why? Because private field syntax can lead to size and performance issues when TypeScript compiles to JavaScript. Additionally, standards prior to ES2015 do not support private field syntax, limiting TypeScript to be compiled to at least ES2015. Furthermore, there is no significant advantage of private field syntax over access modifiers when performing static type and visibility checks.

  1. Use readonly

For properties that will not be assigned outside of the constructor, use the readonly modifier. These properties do not need to have deep immutability.

  1. Parameter Properties

Do not explicitly initialize class members in the constructor. Instead, use TypeScript's parameter properties syntax. Adding a modifier or readonly directly before the constructor parameter is equivalent to defining that property in the class and assigning it a value, making the code more concise.

// Do not do this! Too much repetitive code!
class Foo {
  private readonly barService: BarService
  constructor(barService: BarService) {
    this.barService = barService
  }
}

// Do this! Clear and concise!
class Foo {
  constructor(private readonly barService: BarService) {}
}
  1. Field Initialization

If a member is not a parameter property, it should be initialized at the time of declaration, which can sometimes completely omit the constructor.

// Do not do this! No need to separate the initialization statement in the constructor!
class Foo {
  private readonly userList: string[]
  constructor() {
    this.userList = []
  }
}

// Do this! Omitted the constructor!
class Foo {
  private readonly userList: string[] = []
}
  1. When a subclass inherits from a superclass and needs to override a method, the override modifier should be added.
class Animal {
  eat() {
    console.log('food')
  }
}

// Bad
class Dog extends Animal {
  eat() {
    console.log('bone')
  }
}

// Good
class Dog extends Animal {
  override eat() {
    console.log('bone')
  }
}

Style#

  1. Use arrow functions instead of anonymous function expressions.
// Good
bar(() => {
  this.doSomething()
})

// Bad
bar(function () {})
  1. Only wrap arrow function parameters in parentheses when needed. For example, (x) => x + x is incorrect; the correct way is:
  2. x => x + x
  3. (x,y) => x + y
  4. <T>(x: T, y: T) => x === y
  5. Always use {} to wrap loop bodies and conditional statements.
  6. Do not start with whitespace inside parentheses. There should be a space after commas, colons, and semicolons. For example:
  7. for (let i = 0, n = str.length; i < 10; i++) {}
  8. if (x < 10) {}
  9. function f(x: number, y: string): void {}
  10. Each variable declaration statement should declare only one variable (for example, use let x = 1; let y = 2; instead of let x = 1, y = 2;).
  11. If a function does not return a value, it is best to use void.
  12. Equality checks must use triple equals (===) and corresponding not equals (!==). Double equals will perform type coercion during comparison, which can easily lead to hard-to-understand errors. Additionally, on JavaScript virtual machines, double equals are slower than triple equals. JavaScript Equality Table.

References#

  1. Coding guidelines
  2. TypeScript Handbook
  3. TypeScript Chinese Manual
  4. Google TypeScript Style Guide
  5. Prohibition against prefixing interfaces with "I"
  6. Confused about the Interface and Class coding guidelines for TypeScript
  7. Typescript Development Specifications
  8. What is as const in TypeScript
  9. TypeScript: Prefer Interfaces
  10. What is the difference between interface and type in TypeScript?
  11. TypeScript Declaration Files - Third-party Type Extensions
  12. Effective TypeScript: Tips for Using TypeScript
  13. JavaScript Equality Table
Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.