Author: Wang Zi Ting

As a Node.js developer, I’ve been learning about TypeScript for a long time, but because OF my love of CoffeeScript, I didn’t try out TypeScript until 2016. Until recently, I had developed two back-end projects in TypeScript at work and gained some new understanding of TypeScript.

Add a type to JavaScript

People compare TypeScript to other languages and say that it mimics Java or C#, and I used to believe that. But that’s not the case. TypeScript’s type system and working mechanism is so unique that it can’t be described as simply copying any other language. It’s more like reinventing JavaScript.

At its core, TypeScript is not a completely new language. It adds static constraints to an existing language that is very flexible and dynamically typed. As noted in the TypeScript Design Goals on the official Wiki, TypeScript is not trying to extract a subset of JavaScript with static semantics. Instead, TypeScript is trying to support programming paradigms that already exist in the community. Avoid incompatibilities with common usage.

This means that TypeScript tries to provide static semantics for a number of very “dynamic” features that JavaScript already has. It’s common to think of statically typed variables as being typed at compile time, but TypeScript is special because of the dynamic nature of JavaScript. TypeScript types are more of a “constraint” that respects the established JavaScript design paradigm. Add as many static constraints as you can — constraints that don’t affect the expressiveness of your code. In other words, TypeScript puts the expressiveness of JavaScript first, the runtime behavior of JavaScript first, and static constraints second.

Doesn’t that sound boring? Python has Type Checking, and JavaScript has Flow before it. That’s true, but TypeScript’s expressive type system and toolchain support is so powerful that it doesn’t just cover simple cases like some static type annotations, but is deeply involved in the entire development process.

As mentioned earlier, TypeScript doesn’t want to invent new paradigms, but rather support existing uses of JavaScript as much as possible. So while TypeScript has a powerful type system and a lot of features, it’s not expensive for JavaScript developers to learn because almost every feature corresponds to a common paradigm in the JavaScript community.

Attribute based type system

In JavaScript, Object is one of the most common types. We use a lot of Object literals to organize data. We often stuff a lot of different parameters into an Object, or return an Object from a function. Objects are arguably the most commonly used data containers in JavaScript, but there is no type to constrain them.

The Request library, for example, requires the user to pass in all the parameters for the request as an object. This is a very typical JavaScript style. In JavaScript, for example, a Promise object only needs to have two instance methods, then and catch. It doesn’t really need to come from the Promise constructor in the standard library. There are actually many third-party Promise implementations. Or some library that returns promise-like objects (such as some ORMs).

In JavaScript we usually focus only on whether an object has the properties and methods we need. This paradigm is known as “Duck typing”, which means that a bird can be called a Duck if it walks like a Duck, swims like a Duck, and quacks like a Duck.

So TypeScript opts for a property-based type system that no longer focuses on what type a variable is named (constructed by which constructor), but instead takes objects apart when type checking, Compare each non-subdivided member of the object one by one. If an object has all the attributes or methods required by a type, it can be used as that type.

This is the core of the TypeScript type system — interfaces:

interface LabeledValue {
  label: string
}
Copy the code

TypeScript doesn’t care about the name of an Interface; it’s more of a constraint than a “type.” An object can be said to satisfy the LabeledValue constraint as long as it has a string label attribute. It can be an instance of another class, it can be literal, it can have additional attributes; As long as it satisfies the attributes required by LabeledValue, it can be assigned to variables of that type and passed to parameters of that type.

I mentioned earlier that an Interface is actually a set of attributes or a set of constraints, and when you talk about sets, of course you can do things like intersections and unions. For example, type C = A & B indicates that C must meet the constraints of type A and type B at the same time. And type C = C A | B say need to meet any types of constraints, A and B can realize the joint type (Union type).

I’m going to take a look at some of the features that are typical of TypeScript, and they’re all nicely interlinked.

String magic: Literals

In TypeScript, literals are also types:

type Name = 'ziting'
const myName: Name = 'ziting'
Copy the code

In the above code, the only valid value for the Name type is the string ziting — this seems pointless, but what if we introduced the set operation mentioned earlier (union type)?

type Method = 'GET' | 'PUT' | 'DELETE'

interface Request {
  method: Method
  url: string
}
Copy the code

In the above code we restrict the Request method to be only one of GET, PUT, or DELETE, which is more accurate than simply restricting it to a string type. This is a pattern often used by JavaScript developers — enumeration types are represented by strings, which are more flexible and readable.

In libraries like LoDash, JavaScript developers also like to use strings to pass property names, which is error-prone in JavaScript. TypeScript provides special syntax and built-in utility types to evaluate these string literals, providing static type checking:

interface Todo {
  title: string
  description: string
  completed: boolean
}

// keyof extracts all the attribute names of the interface into a new union type
type KeyOfTodo = keyof Todo // 'title' | 'description' | 'completed'
// Pick can extract a set of attributes from an interface to generate a new type
type TodoPreview = Pick<Todo, 'title' | 'completed'> // {title: string, completed: boolean}
// Extract can find the intersection of two union types to generate a new type
type Inter = Extract<keyof Todo, 'title' | 'author'> // 'title'
Copy the code

With this syntax and the generics capabilities mentioned below, JavaScript can also get accurate type checking by passing property names as strings and by wizardry handling objects.

Type metaprogramming: Generics

Generics provide the ability to parameterize types. Their most basic use in other languages is to define container types so that utility functions don’t have to know the exact type of the variable being operated on. Arrays or promises in JavaScript are expressed as generic types in TypeScript. For example, the type definition for promise. all can be written as:

function all<T> (values: Array<T | Promise<T>>) :Promise<Array<T>>
Copy the code

You can see that type parameters can be used to construct more complex types, perform set operations, or nest them.

By default, because a type parameter can be of any type, it cannot be assumed to have properties or methods, and therefore cannot access any of its properties. It can only be used if a constraint is added, and TypeScript restricts incoming types to that constraint:

interface Lengthwise {
  length: number
}

function logLength<T extends Lengthwise> (arg: T) {
  console.log(arg.length)
}
Copy the code

The constraint can also use other type arguments or multiple type arguments. In the following code we restrict the type argument K to be an attribute name of obj:

function getProperty<T.K extends keyof T> (obj: T, key: K) {
  return obj[key];
}
Copy the code

In addition to using generics on functions, we can also define generic types:

type Partial<T> = {
  [P inkeyof T]? : T[P]; }Copy the code

When we define generic types we are actually defining a “function” that handles the type, using generic parameters to generate new types, which is also known as “metaprogramming.” Partial, for example, iterates over every attribute of type T passed in, returning a new type with nullable all attributes:

interface Person {
  name: string
}

const a: Person = {} Property 'name' is missing in type '{}' but required in type 'Person'.
const b: Partial<Person> = {}
Copy the code

The Pick and Extract types mentioned earlier are generic types like this.

Beyond that, TypeScript can even do conditional judgment and recursion when defining generic types, making TypeScript’s type system Turing-complete and computation-ready at compile time.

You may wonder if such a complex type is really useful. These features are more for library developers to use, and such a robust type system is necessary for JavaScript community ORMs, data structures, or libraries like LoDash, whose type-defining lines are dozens of times larger than their own code.

Type equations: automatic derivation

But it is not necessary to master such a complex type system, and in fact the advanced features described above are rarely used in business code. TypeScript doesn’t want to be too burdensome for developers to annotate types, so TypeScript does as much type derivation as possible so that developers don’t have to manually annotate types in most cases.

const bool = true // bool is true (literal type)
let num = 1 / / num is number
let arr = [0.1.'str'] // arr 是 (number | string)[]

let body = await fs.readFile() / / body is Buffer

// cpuModels 是 string[]
let cpuModels = os.cpus().map( cpu= > {
  / / the CPU is OS. CpuInfo
  return cpu.model
})
Copy the code

Type inference can also be used with generics, such as the aforementioned promise. all and getProperty, without having to worry about generic arguments:

// Call promise. all
      
       , files of type Promise
       []>
      
const files = Promise.all(paths.map( path= > fs.readFile(path)))
// call promise. all
      
       , numbers is of type Promise
       []>
      []>
const numbers = Promise.all([1.2.3.4])

// call getProperty<{a: number}, 'a'> where a is of type number
const a = getProperty({a: 2}, 'a')
Copy the code

As mentioned earlier, generics parameterize types by introducing an unknown number in place of the actual type, so generics are like an equation for TypeScript. As long as you provide other unknowns that solve the equation, TypeScript can derive the rest of the generic type.

A billion dollar mistake

In many languages, accessing a null pointer raises an exception (in JavaScript, when reading a property from null or undefined), and null pointer exceptions are referred to as “billion-dollar errors.” TypeScript also provides support for null-value checking (strictNullChecks are enabled). Although this depends on the correctness of type definitions, there is no runtime guarantee, but it can still catch most errors at compile time and improve development efficiency.

In TypeScript, types cannot be null (undefined or null). Nullable types must be represented as union types with undefined or null, so that when you try to read a property from a variable that might be undefined, TypeScript will report errors.

function logDateValue1(date: Date) { // The parameter cannot be null
  console.log(date.valueOf())
}

logDateValue1(new Date)
logDateValue1() // An argument for 'date' was not provided

function logDateValue2(date: Date | undefined) { // The parameter can be null
  console.log(date.valueOf()) // Object is possibly 'undefined'.
}

logDateValue2(new Date)
logDateValue2()
Copy the code

In this case, TypeScript asks you to evaluate the value to exclude the possibility that it is undefined. This brings us to another feature of TypeScript — its controlflow-based type analysis. For example, after you use if to make a non-empty check on a variable, the variable becomes a non-empty type in braces after if:

function print(str: string | null) {
  / / STR is of type string here | null
  console.log(str.trim()) Object is possibly 'null'
  if(str ! = =null) {
    // STR is of type string
    console.log(str.trim())
  }
}
Copy the code

The same type analysis occurs when determining union types using if, switch, etc. :

interface Rectangle {
  kind: 'rectangle'
  width: number
  height: number
}

interface Circle {
  kind: 'circle'
  radius: number
}

function area(s: Rectangle | Circle) {
  / / s type here is a Rectangle | Circle
  switch (s.kind) {
    case 'rectangle':
      // Rectangle is a Rectangle
      return s.height * s.width
    case 'circle':
      // s is of type Circle
      return Math.PI * s.radius ** 2; }}Copy the code

Work only in the compile phase

TypeScript is still eventually compiled into JavaScript and executed by JavaScript engines such as V8. The resulting code does not contain any type information, and TypeScript does not add any functionality related to runtime behavior.

TypeScript only provides type checking, but it doesn’t guarantee that the code that passes the check will run correctly. A variable may be a number in the TypeScript type declaration, but that doesn’t prevent it from becoming a string at runtime — it may be cast or some other non-typescript library and the type definition file is wrong.

In TypeScript you can set the type to any to bypass almost all checks, or use AS to force a “cast” type. Of course, as mentioned earlier, this only converts TypeScript’s compile-time type notation, not the runtime type. Although TypeScript is designed to support all the paradigms of JavaScript, there are some extreme use cases that can’t be covered, and using any can be very testing for developers.

Programming languages have always had to trade off type systems between flexibility and complexity, simplicity and rigidity. TypeScript offers a completely different answer, separating compile-time checking from runtime behavior. This is one of TypeScript’s most controversial issues. Some people think it’s very insecure and can get the wrong type at run time even if you pass compile-time checking, while others think it’s a very practical engineering choice — you can skip type checking with any, Adding code that is too complex or unimplementable breaks type safety, but it does solve the problem.

So does it make sense to do type checking only at compile time? I think it certainly does, given that JavaScript already provides enough runtime behavior to work with while maintaining interoperability with JavaScript. What is needed is TypeScript type checking to improve development efficiency. In addition to compile-time checking to catch errors early, TypeScript type information can also give editors (ides) very accurate suggestions for completion.

Work with JavaScript code

Any javascript-based technology has to deal with interoperability with standard JavaScript code — TypeScript cannot create a parallel JavaScript world, it has to rely on the hundreds of thousands of JavaScript packages already in the community.

So TypeScript introduces a type description file that allows the community to write type description files for JavaScript so that the code that uses them can get TypeScript type checking.

Description files are indeed one of the biggest pain points in TypeScript development. After all, there is no smooth development experience until you have found all the definition files. It is inevitable that some domain-specific, niche libraries will be used in the development process, so you must consider whether the library has a definition file, the quality of the definition file, and whether you need to write a definition file for it. For libraries that don’t involve complex generics, it doesn’t take much time to write definition files, and you only need to write definitions for the interfaces you use, but it’s still a distraction.

summary

TypeScript has an advanced type system, not in the “academic” sense, but in the “engineering” sense, that actually improves development efficiency, reduces the psychological burden of dynamic typing, and finds errors ahead of time. So we recommend that all JavaScript developers get to know TypeScript and try it out. It’s very cheap to get started.

In LeanCloud, the console switched to TypeScript in a recent refactoring, increasing the engineering level of the front-end project and making the code maintainable over time. At the same time, some of our existing Node.js-based back-end projects are switching to TypeScript.

Some of LeanCloud’s internal tools and edge services will also prioritize TypeScript, which is cheaper to learn (who hasn’t written a few lines of JavaScript?). , static type checking, and excellent IDE support greatly reduce the barrier for new colleagues to participate in unfamiliar or unmaintained projects and increase the motivation to improve internal tools.

LeanCloud’s JavaScript SDK, Node SDK, and Play SDK all add TypeScript definition files (and are intended to be rewritten with TypeScript in future versions). Enabling developers using LeanCloud to use the SDK in TypeScript, definition files help editors improve code completion and type hints even when they don’t use TypeScript.

If you want to work on these projects as well, check out the opportunities available at LeanCloud.

References:

  • TypeScript Evolution
  • TypeScript Deep Dive
  • TypeScript Design Goals
  • The TypeScript Handbook
  • Overview of the TypeScript type system
  • TypeScript type metaprogramming: Implements 8-digit arithmetic operations
  • Programming wisdom (handling null Pointers correctly)
  • The Worst Mistake of Computer Science

LeanCloud, the leading provider of BaaS, provides strong back-end support for mobile development. Follow LeanCloud Newsletter for more content