Tuesday, February 18, 2020

Handbook: Type Inference (suy đoán kiểu dữ liệu)

Introduction #

In this section, we will cover type inference in TypeScript. Namely, we’ll discuss where and how types are inferred.

Basics #

In TypeScript, there are several places where type inference is used to provide type information when there is no explicit type annotation. For example, in this code
let x = 3;
The type of the x variable is inferred to be number. This kind of inference takes place when initializing variables and members, setting parameter default values, and determining function return types.
In most cases, type inference is straightforward. In the following sections, we’ll explore some of the nuances in how types are inferred.

Best common type #

When a type inference is made from several expressions, the types of those expressions are used to calculate a “best common type”. For example,
let x = [0, 1, null];
To infer the type of x in the example above, we must consider the type of each array element. Here we are given two choices for the type of the array: number and null. The best common type algorithm considers each candidate type, and picks the type that is compatible with all the other candidates.
Because the best common type has to be chosen from the provided candidate types, there are some cases where types share a common structure, but no one type is the super type of all candidate types. For example:
let zoo = [new Rhino(), new Elephant(), new Snake()];
Ideally, we may want zoo to be inferred as an Animal[], but because there is no object that is strictly of type Animal in the array, we make no inference about the array element type. To correct this, instead explicitly provide the type when no one type is a super type of all other candidates:
let zoo: Animal[] = [new Rhino(), new Elephant(), new Snake()];
When no best common type is found, the resulting inference is the union array type, (Rhino | Elephant | Snake)[].

Contextual Typing #

Type inference also works in “the other direction” in some cases in TypeScript. This is known as “contextual typing”. Contextual typing occurs when the type of an expression is implied by its location. For example:
window.onmousedown = function(mouseEvent) {
    console.log(mouseEvent.button);   //<- OK
    console.log(mouseEvent.kangaroo); //<- Error!
};
Here, the TypeScript type checker used the type of the Window.onmousedown function to infer the type of the function expression on the right hand side of the assignment. When it did so, it was able to infer the type of the mouseEvent parameter, which does contain a button property, but not a kangaroo property.
TypeScript is smart enough to infer types in other contexts as well:
window.onscroll = function(uiEvent) {
    console.log(uiEvent.button); //<- Error!
}
Based on the fact that the above function is being assigned to Window.onscroll, TypeScript knows that uiEvent is a UIEvent, and not a MouseEvent like the previous example. UIEvent objects contain no button property, and so TypeScript will throw an error.
If this function were not in a contextually typed position, the function’s argument would implicitly have type any, and no error would be issued (unless you are using the --noImplicitAny option):
const handler = function(uiEvent) {
    console.log(uiEvent.button); //<- OK
}
We can also explicitly give type information to the function’s argument to override any contextual type:
window.onscroll = function(uiEvent: any) {
    console.log(uiEvent.button);  //<- Now, no error is given
};
However, this code will log undefined, since uiEvent has no property called button.
Contextual typing applies in many cases. Common cases include arguments to function calls, right hand sides of assignments, type assertions, members of object and array literals, and return statements. The contextual type also acts as a candidate type in best common type. For example:
function createZoo(): Animal[] {
    return [new Rhino(), new Elephant(), new Snake()];
}
In this example, best common type has a set of four candidates: AnimalRhinoElephant, and Snake. Of these, Animal can be chosen by the best common type algorithm.

Handbook: Enums

Enums allow us to define a set of named constants. Using enums can make it easier to document intent, or create a set of distinct cases. TypeScript provides both numeric and string-based enums.

Numeric enums #

We’ll first start off with numeric enums, which are probably more familiar if you’re coming from other languages. An enum can be defined using the enum keyword.
enum Direction {
    Up = 1,
    Down,
    Left,
    Right,
}
Above, we have a numeric enum where Up is initialized with 1. All of the following members are auto-incremented from that point on. In other words, Direction.Up has the value 1Down has 2Left has 3, and Right has 4.
If we wanted, we could leave off the initializers entirely:
enum Direction {
    Up,
    Down,
    Left,
    Right,
}
Here, Up would have the value 0Down would have 1, etc. This auto-incrementing behavior is useful for cases where we might not care about the member values themselves, but do care that each value is distinct from other values in the same enum.
Using an enum is simple: just access any member as a property off of the enum itself, and declare types using the name of the enum:
enum Response {
    No = 0,
    Yes = 1,
}

function respond(recipient: string, message: Response): void {
    // ...
}

respond("Princess Caroline", Response.Yes)
Numeric enums can be mixed in computed and constant members (see below). The short story is, enums without initializers either need to be first, or have to come after numeric enums initialized with numeric constants or other constant enum members. In other words, the following isn’t allowed:
enum E {
    A = getSomeValue(),
    B, // Error! Enum member must have initializer.
}

String enums #

String enums are a similar concept, but have some subtle runtime differences as documented below. In a string enum, each member has to be constant-initialized with a string literal, or with another string enum member.
enum Direction {
    Up = "UP",
    Down = "DOWN",
    Left = "LEFT",
    Right = "RIGHT",
}
While string enums don’t have auto-incrementing behavior, string enums have the benefit that they “serialize” well. In other words, if you were debugging and had to read the runtime value of a numeric enum, the value is often opaque - it doesn’t convey any useful meaning on its own (though reverse mapping can often help), string enums allow you to give a meaningful and readable value when your code runs, independent of the name of the enum member itself.

Heterogeneous enums #

Technically enums can be mixed with string and numeric members, but it’s not clear why you would ever want to do so:
enum BooleanLikeHeterogeneousEnum {
    No = 0,
    Yes = "YES",
}
Unless you’re really trying to take advantage of JavaScript’s runtime behavior in a clever way, it’s advised that you don’t do this.

Computed and constant members #

Each enum member has a value associated with it which can be either constant or computed. An enum member is considered constant if:
  • It is the first member in the enum and it has no initializer, in which case it’s assigned the value 0:
    // E.X is constant:
    enum E { X }
    
  • It does not have an initializer and the preceding enum member was a numeric constant. In this case the value of the current enum member will be the value of the preceding enum member plus one.
    // All enum members in 'E1' and 'E2' are constant.
    
    enum E1 { X, Y, Z }
    
    enum E2 {
        A = 1, B, C
    }
    
  • The enum member is initialized with a constant enum expression. A constant enum expression is a subset of TypeScript expressions that can be fully evaluated at compile time. An expression is a constant enum expression if it is:
    1. a literal enum expression (basically a string literal or a numeric literal)
    2. a reference to previously defined constant enum member (which can originate from a different enum)
    3. a parenthesized constant enum expression
    4. one of the +-~ unary operators applied to constant enum expression
    5. +-*/%<<>>>>>&|^ binary operators with constant enum expressions as operands
    It is a compile time error for constant enum expressions to be evaluated to NaN or Infinity.
In all other cases enum member is considered computed.
enum FileAccess {
    // constant members
    None,
    Read    = 1 << 1,
    Write   = 1 << 2,
    ReadWrite  = Read | Write,
    // computed member
    G = "123".length
}

Union enums and enum member types #

There is a special subset of constant enum members that aren’t calculated: literal enum members. A literal enum member is a constant enum member with no initialized value, or with values that are initialized to
  • any string literal (e.g. "foo""bar"baz")
  • any numeric literal (e.g. 1100)
  • a unary minus applied to any numeric literal (e.g. -1-100)
When all members in an enum have literal enum values, some special semantics come to play.
The first is that enum members also become types as well! For example, we can say that certain members can only have the value of an enum member:
enum ShapeKind {
    Circle,
    Square,
}

interface Circle {
    kind: ShapeKind.Circle;
    radius: number;
}

interface Square {
    kind: ShapeKind.Square;
    sideLength: number;
}

let c: Circle = {
    kind: ShapeKind.Square, // Error! Type 'ShapeKind.Square' is not assignable to type 'ShapeKind.Circle'.
    radius: 100,
}
The other change is that enum types themselves effectively become a union of each enum member. While we haven’t discussed union types yet, all that you need to know is that with union enums, the type system is able to leverage the fact that it knows the exact set of values that exist in the enum itself. Because of that, TypeScript can catch silly bugs where we might be comparing values incorrectly. For example:
enum E {
    Foo,
    Bar,
}

function f(x: E) {
    if (x !== E.Foo || x !== E.Bar) {
        //             ~~~~~~~~~~~
        // Error! This condition will always return 'true' since the types 'E.Foo' and 'E.Bar' have no overlap.
    }
}
In that example, we first checked whether x was not E.Foo. If that check succeeds, then our || will short-circuit, and the body of the ‘if’ will run. However, if the check didn’t succeed, then x can only be E.Foo, so it doesn’t make sense to see whether it’s equal to E.Bar.

Enums at runtime #

Enums are real objects that exist at runtime. For example, the following enum
enum E {
    X, Y, Z
}
can actually be passed around to functions
function f(obj: { X: number }) {
    return obj.X;
}

// Works, since 'E' has a property named 'X' which is a number.
f(E);

Enums at compile time #

Even though Enums are real objects that exist at runtime, the keyof keyword works differently than you might expect for typical objects. Instead, use keyof typeof to get a Type that represents all Enum keys as strings.
enum LogLevel {
    ERROR, WARN, INFO, DEBUG
}

/**
 * This is equivalent to:
 * type LogLevelStrings = 'ERROR' | 'WARN' | 'INFO' | 'DEBUG';
 */
type LogLevelStrings = keyof typeof LogLevel;

function printImportant(key: LogLevelStrings, message: string) {
    const num = LogLevel[key];
    if (num <= LogLevel.WARN) {
       console.log('Log level key is: ', key);
       console.log('Log level value is: ', num);
       console.log('Log level message is: ', message);
    }
}
printImportant('ERROR', 'This is a message');

Reverse mappings #

In addition to creating an object with property names for members, numeric enums members also get a reverse mapping from enum values to enum names. For example, in this example:
enum Enum {
    A
}
let a = Enum.A;
let nameOfA = Enum[a]; // "A"
TypeScript might compile this down to something like the following JavaScript:
var Enum;
(function (Enum) {
    Enum[Enum["A"] = 0] = "A";
})(Enum || (Enum = {}));
var a = Enum.A;
var nameOfA = Enum[a]; // "A"
In this generated code, an enum is compiled into an object that stores both forward (name -> value) and reverse (value -> name) mappings. References to other enum members are always emitted as property accesses and never inlined.
Keep in mind that string enum members do not get a reverse mapping generated at all.

const enums #

In most cases, enums are a perfectly valid solution. However sometimes requirements are tighter. To avoid paying the cost of extra generated code and additional indirection when accessing enum values, it’s possible to use const enums. Const enums are defined using the const modifier on our enums:
const enum Enum {
    A = 1,
    B = A * 2
}
Const enums can only use constant enum expressions and unlike regular enums they are completely removed during compilation. Const enum members are inlined at use sites. This is possible since const enums cannot have computed members.
const enum Directions {
    Up,
    Down,
    Left,
    Right
}

let directions = [Directions.Up, Directions.Down, Directions.Left, Directions.Right]
in generated code will become
var directions = [0 /* Up */, 1 /* Down */, 2 /* Left */, 3 /* Right */];

Ambient enums #

Ambient enums are used to describe the shape of already existing enum types.
declare enum Enum {
    A = 1,
    B,
    C = 2
}
One important difference between ambient and non-ambient enums is that, in regular enums, members that don’t have an initializer will be considered constant if its preceding enum member is considered constant. In contrast, an ambient (and non-const) enum member that does not have initializer is always considered computed.