TypeScript official manual translation plan [v]: object type

Keywords: Front-end TypeScript

  • Note: at present, there is no Chinese translation of the latest official documents of TypeScript on the Internet, so there is such a translation plan. Because I am also a beginner of TypeScript, I can't guarantee the 100% accuracy of translation. If there are errors, please point them out in the comment area;
  • Translation content: the tentative translation content is TypeScript Handbook , other parts of the translated documents will be supplemented when available;
  • Project address: TypeScript-Doc-Zh , if it helps you, you can click a star~

Official document address of this chapter: Object Types

object type

In JavaScript, the most basic way to group and pass data is to use objects. In TypeScript, we represent it by object type.

As you saw earlier, object types can be anonymous:

function greet(person: { name: string; age: number }) {
  return "Hello " + person.name;
}

Alternatively, you can use an interface to name:

interface Person {
  name: string;
  age: number;
}
 
function greet(person: Person) {
  return "Hello " + person.name;
}

Or use a type alias:

type Person = {
  name: string;
  age: number;
};
 
function greet(person: Person) {
  return "Hello " + person.name;
}

In the above example, the object accepted by the function we wrote contains the name attribute (the type must be string) and the age attribute (the type must be number).

Attribute modifier

Each attribute in the object type can specify something: attribute type, whether the attribute is optional, and whether the attribute is writable.

optional attribute

Most of the time, we will find that the object we deal with may have a property set. In this case, we can add the following after the names of these attributes:? Symbols and mark them as optional attributes.

interface PaintOptions {
  shape: Shape;
  xPos?: number;
  yPos?: number;
}
 
function paintShape(opts: PaintOptions) {
  // ...
}
 
const shape = getShape();
paintShape({ shape });
paintShape({ shape, xPos: 100 });
paintShape({ shape, yPos: 100 });
paintShape({ shape, xPos: 100, yPos: 100 });

In this example, both xPos and yPos are optional attributes. We can choose to provide or not provide these two properties, so the paintShape call above is valid. What selectivity really wants to express is that if this property is set, it's better to have a specific type.

These properties are also accessible -- but if enabled strictNullChecks , the TypeScript will prompt us that these attributes may be undefined.

function paintShape(opts: PaintOptions) {
  let xPos = opts.xPos;
                  ^^^^
            // (property) PaintOptions.xPos?: number | undefined
  let yPos = opts.yPos;
                  ^^^^   
            // (property) PaintOptions.yPos?: number | undefined
  // ...
}

In JavaScript, even if a property has never been set, we can still access it -- the value is undefined. We can make special treatment for undefined.

function paintShape(opts: PaintOptions) {
  let xPos = opts.xPos === undefined ? 0 : opts.xPos;
      ^^^^ 
    // let xPos: number
  let yPos = opts.yPos === undefined ? 0 : opts.yPos;
      ^^^^ 
    // let yPos: number
  // ...
}

Note that this mode of setting default values for unspecified values is common, so JavaScript provides syntax level support.

function paintShape({ shape, xPos = 0, yPos = 0 }: PaintOptions) {
  console.log("x coordinate at", xPos);
                                 ^^^^ 
                            // (parameter) xPos: number
  console.log("y coordinate at", yPos);
                                 ^^^^ 
                            // (parameter) yPos: number
  // ...
}

Here we use for the parameters of paintShape Deconstruction model It also provides for xPos and yPos Default value . Now, xPos and yPos must have values in the paintShape function body, and these two parameters are still optional when calling the function.

Note that there is currently no way to use type annotations in Deconstruction patterns. This is because the following syntax has other semantics in JavaScript

function draw({ shape: Shape, xPos: number = 100 /*...*/ }) {
  render(shape);
        ^^^^^^
     // Cannot find name 'shape'. Did you mean 'Shape'?
  render(xPos);
         ^^^^^
    // Cannot find name 'xPos'.
}

In an object deconstruction mode, shape: Shape means "capture the shape attribute and redefine it as a local variable named shape". Similarly, xPos: number will also create a variable named number, whose value is the value of xPos in the parameter.

use Mapping modifier Optional attributes can be removed.

Read only attribute

In TypeScript, we can mark the attribute as readonly, indicating that it is a read-only attribute. Although this does not change any behavior at runtime, the property marked readonly cannot be overridden during type checking.

interface SomeType {
  readonly prop: string;
}
 
function doSomething(obj: SomeType) {
  // obj.prop can be read
  console.log(`prop has the value '${obj.prop}'.`);
 
  // But it cannot be reassigned
  obj.prop = "hello";
// Cannot assign to 'prop' because it is a read-only property.
}

Using the readonly modifier does not necessarily mean that a value is completely immutable -- or, in other words, that its contents are immutable. Readonly only indicates that the property itself cannot be overridden.

interface Home {
  readonly resident: { name: string; age: number };
}
 
function visitForBirthday(home: Home) {
  // We can read and update the home.resident property
  console.log(`Happy birthday ${home.resident.name}!`);
  home.resident.age++;
}
 
function evict(home: Home) {
  // But we can't override the resident attribute of the Home type itself
  home.resident = {
       ^^^^^^^^
// Cannot assign to 'resident' because it is a read-only property.
    name: "Victor the Evictor",
    age: 42,
  };
}

It is important to understand the meaning of readonly. In the process of developing with TypeScript, it can effectively indicate how an object should be used. When TypeScript checks whether two types are compatible, it does not consider whether their properties are read-only, so read-only properties can also be modified through aliases.

interface Person {
  name: string;
  age: number;
}
 
interface ReadonlyPerson {
  readonly name: string;
  readonly age: number;
}
 
let writablePerson: Person = {
  name: "Person McPersonface",
  age: 42,
};
 
// It can be executed normally
let readonlyPerson: ReadonlyPerson = writablePerson;
 
console.log(readonlyPerson.age); // Print 42
writablePerson.age++;
console.log(readonlyPerson.age); // Print 43

use Mapping modifier You can remove read-only attributes.

Index signature

Sometimes you can't know the names of all attributes of a type in advance, but you know the types of these attribute values. In this case, you can use index signatures to describe the types of possible values. for instance:

interface StringArray {
    [index: number]: string
}
const myArray: StringArray = getStringArray();
const secondItem = myArray[1];
      ^^^^^^^^^^    
     // const secondItem: string

In the above code, the StringArray interface has an index signature. This index signature indicates that when StringArray is indexed by a value of type number, it will return a value of type string.

The attribute type of an index signature is either string or number.

Of course, you can also support two types at the same time

However, the precondition is that the type returned by the numeric index must be a subtype of the type returned by the string index. This is because when you use numeric values to index object properties, JavaScript actually converts the values into strings first. This means that indexing with 100 (numeric) has the same effect as indexing with "100" (string), so the two must be consistent.

interface Animal {
  name: string;
}
 
interface Dog extends Animal {
  breed: string;
}
 
// Error: indexing with a numeric string might get you a completely separate type of Animal!
interface NotOkay {
  [x: number]: Animal;
// 'number' index type 'Animal' is not assignable to 'string' index type 'Dog'.
  [x: string]: Dog;
}

However, if the type described by the index signature itself is the union type of each attribute type, different types of attributes are allowed:

interface NumberOrStringDictionary {
  [index: string]: number | string;
  length: number; // length is a number and can be
  name: string; // name is a string. You can
}

Finally, you can set the index signature to be read-only, which can prevent the attribute of the corresponding index from being re assigned:

interface ReadonlyStringArray {
  readonly [index: number]: string;
}
 
let myArray: ReadonlyStringArray = getReadOnlyStringArray();
myArray[2] = "Mallory";
// Index signature in type 'ReadonlyStringArray' only permits reading.

The value of myArray[2] cannot be changed because the index signature is set to read-only.

Expansion type

It is a common requirement to expand a more specific type based on a type. For example, we have a basic address type to describe the address information needed to mail a letter or package.

interface BasicAddress {
    name?: string;
    street: string;
    city: string;
    country: string;
    postalCode: string;
}

Usually, this information is enough, but if there are many units in the building of an address, the address information usually needs to have a unit number. At this time, we can use an AddressWithUnit to describe the address information:

interface AddressWithUnit {
    name?: string;
    unit: string;
    street: string;
    city: string;
    country: string;
    postalCode: string;
}

Of course, this is no problem, but the disadvantage is that although only one domain is added, we have to write all the domains in basic address repeatedly. Instead, we can expand the original basic address type and add a new domain unique to AddressWithUnit.

interface BasicAddress {
  name?: string;
  street: string;
  city: string;
  country: string;
  postalCode: string;
}
 
interface AddressWithUnit extends BasicAddress {
  unit: string;
}

The extends keyword following an interface allows us to efficiently copy members from other named types and add any new members we want. This has a great effect on reducing the type declaration statements we have to write, and it can also indicate the relationship between several different type declarations with the same attribute. For example, AddressWithUnit does not need to write the street attribute repeatedly, and because the street attribute comes from basic address, developers can know that there is a certain relationship between the two types.

Interfaces can also be extended from multiple types:

interface Colorful {
  color: string;
}
 
interface Circle {
  radius: number;
}
 
interface ColorfulCircle extends Colorful, Circle {}
 
const cc: ColorfulCircle = {
  color: "red",
  radius: 42,
};

Cross type

Interfaces allow us to build new types by extending existing types. TypeScript also provides another structure called "cross type", which can be used to combine existing object types.

An intersection type can be defined by the & operator:

interface Colorful {
    color: string;
}
interface Circle {
    radius: number;
}
type ColorfulCircle = Colorful & Circle;

Here, we combine the Colorful and Circle types to produce a new type, which has all the members of Colorful and Circle.

function draw(circle: Colorful & Circle) {
  console.log(`Color was ${circle.color}`);
  console.log(`Radius was ${circle.radius}`);
}
 
// Can run
draw({ color: "blue", radius: 42 });
 
// Cannot run
draw({ color: "red", raidus: 42 });
/*
Argument of type '{ color: string; raidus: number; }' is not assignable to parameter of type 'Colorful & Circle'.
  Object literal may only specify known properties, but 'raidus' does not exist in type 'Colorful & Circle'. Did you mean to write 'radius'?
*/ 

Interface VS cross type

At present, we have learned that two similar but different types can be combined in two ways. Using the interface, we can expand the original types through the extends clause; Using cross types, we can achieve a similar effect, and use type aliases to name new types. The essential difference between the two lies in the way they handle conflicts, and this difference is usually the main reason why we choose between the type aliases of interfaces and cross types.

Generic object type

Suppose we have a Box type, which may contain any type of value: string, number, Giraffe, etc.

interface Box {
    contents: any;
}

Now, the type of the contents attribute is any, which is certainly no problem, but using any may lead to type safety problems.

So we can use unknown instead. But this means that as long as we know the type of contents, we need to do a preventive check or use error prone type assertions.

interface Box {
  contents: unknown;
}
 
let x: Box = {
  contents: "hello world",
};
 
// We can check x.contents
if (typeof x.contents === "string") {
  console.log(x.contents.toLowerCase());
}
 
// Or use type assertions
console.log((x.contents as string).toLowerCase());

Another way to ensure type safety is to create different Box types for each different type of content.

interface NumberBox {
  contents: number;
}
 
interface StringBox {
  contents: string;
}
 
interface BooleanBox {
  contents: boolean;
}

But this means that we need to create different functions or function overloads to operate different Box types.

function setContents(box: StringBox, newContents: string): void;
function setContents(box: NumberBox, newContents: number): void;
function setContents(box: BooleanBox, newContents: boolean): void;
function setContents(box: { contents: any }, newContents: any) {
  box.contents = newContents;
}

This leads to a lot of boilerplate code. Moreover, we may introduce new types and overloads later, which is somewhat redundant. After all, our Box types and overloads are only different types and essentially the same.

Instead, let the Box type declare a type parameter and use generics.

interface Box<Type> {
    contents: Type;
}

You can interpret this code as "the Type of Box is Type, and the Type of its contents is Type". Next, when we reference Box, we need to pass a Type parameter instead of Type.

let box: Box<string>;

If Box is regarded as a template of actual Type, then Type is a placeholder that will be replaced by other types. When TypeScript sees Box < string >, it will replace all types in Box < Type > with strings to get an object similar to {contents: string}. In other words, Box < string > is equivalent to the StringBox in the previous example.

interface Box<Type> {
  contents: Type;
}
interface StringBox {
  contents: string;
}
 
let boxA: Box<string> = { contents: "hello" };
boxA.contents;
     ^^^^^^^^   
    // (property) Box<string>.contents: string
 
let boxB: StringBox = { contents: "world" };
boxB.contents;
     ^^^^^^^^   
    // (property) StringBox.contents: string

Because Type can be replaced by any Type, Box is reusable. This means that when our contents needs a new Type, there is no need to declare a new Box Type at all (although there is no problem doing so).

interface Box<Type> {
    contents: Type;
}
interface Apple {
    //...
}
// Same as {contents: Apple}
type AppleBox = Box<Apple>;

This also means that by using Generic Functions , we can completely avoid overloading.

function setContents<Type>(box: Box<Type>, newContents: Type) {
    box.contents = newContents;
}

It is worth noting that type aliases can also use generics. Previously defined box < type > interface:

interface Box<Type> {
    contents: Type;
}

You can override to the following type alias:

type Box<Type> = {
    contents: Type;
};

Unlike interfaces, type aliases can not only be used to describe object types. So we can also write other generic tool types using type aliases.

type OrNull<Type> = Type | null;
type OneOrMany<Type> = Type | Type[];
type OneOrManyOrNull<Type> = OrNull<OneOrMany<Type>>;
     ^^^^^^^^^^^^^^
    //type OneOrManyOrNull<Type> = OneOrMany<Type> | null
type OneOrManyOrNullStrings = OneOrManyOrNull<string>;
     ^^^^^^^^^^^^^^^^^^^^^^          
    // type OneOrManyOrNullStrings = OneOrMany<string> | null         

We'll come back to type aliases later.

Array type

Generic object types are usually container types that work independently of the types of members they contain. Data structures work ideally in this way and can be reused even if the data types are different.

In fact, in this manual, we have been dealing with a generic type, that is, the Array type. The number [] type or string [] type we write are actually short for Array < number > and Array < string >.

function doSomething(value: Array<string>) {
  // ...
}
 
let myArray: string[] = ["hello", "world"];
 
// The following two writing methods are OK!
doSomething(myArray);
doSomething(new Array("hello", "world"));

Just like the Box type above, Array itself is also a generic type:

interface Array<Type> {
  /**
   * Gets or sets the length of the array
   */
  length: number;
 
  /**
   * Removes the last element of the array and returns it
   */
  pop(): Type | undefined;
 
  /**
   * Adds a new element to the array and returns the new length of the array
   */
  push(...items: Type[]): number;
 
  // ...
}

Modern JavaScript also provides other generic data structures, such as Map < K, V >, Set < T > and Promise < T >. This actually means that the representations of Map, Set, and Promise enable them to handle any Set of types.

Read only array type

ReadonlyArray is a special type that describes an array that cannot be modified.

function doStuff(values: ReadonlyArray<string>) {
  // We can read values 
  const copy = values.slice();
  console.log(`The first value is ${values[0]}`);
 
  // ... but values cannot be modified
  values.push("hello!");
         ^^^^
        // Property 'push' does not exist on type 'readonly string[]'.
}

Like the readonly modifier of the attribute, it is mainly a tool to indicate intention. When we see a function returning ReadonlyArray, it means that we do not intend to modify the array; When we see a function that accepts ReadonlyArray as an argument, it means that we can pass any array to the function without worrying about the array being modified.

Unlike Array, ReadonlyArray has no corresponding constructor to use.

new ReadonlyArray("red", "green", "blue");
    ^^^^^^^^^^^^^
// 'ReadonlyArray' only refers to a type, but is being used as a value here.

However, we can assign a normal Array to ReadonlyArray.

const roArray: ReadonlyArray<string> = ["red","green","blue"];

TypeScript not only provides the shorthand Type [] for array < Type >, but also provides the shorthand readonly Type [] for readonlyarray < Type >.

function doStuff(values: readonly string[]) {
  // We can read values
  const copy = values.slice();
  console.log(`The first value is ${values[0]}`);
 
  // ... but values cannot be modified
  values.push("hello!");
        ^^^^^
      // Property 'push' does not exist on type 'readonly string[]'.
}

The last thing to note is that unlike the readonly attribute modifier, the assignability between ordinary Array and ReadonlyArray is not bidirectional.

let x: readonly string[] = [];
let y: string[] = [];
 
x = y;
y = x;
^
// The type 'readonly string[]' is 'readonly' and cannot be assigned to the mutable type 'string[]'.

Tuple type

Tuple type is a special Array type. Its number of elements and the corresponding type of each element are clear,

type StringNumberPair = [string, number];

Here, StringNumberPair is a tuple type containing string type and number type. Like ReadonlyArray, it has no corresponding runtime representation, but it is still very important for TypeScript. For the type system, StringNumberPair describes such an array: the position with subscript 0 contains a value of type string, and the position with subscript 1 contains a value of type number.

function doSomething(pair: [string, number]) {
  const a = pair[0];
        ^
     //const a: string
  const b = pair[1];
        ^        
     // const b: number
  // ...
}
 
doSomething(["hello", 42]);

If the subscript is out of bounds when accessing tuple elements, an error will be thrown.

function doSomething(pair: [string, number]) {
  // ...
 
  const c = pair[2];
                ^    
// Tuple type '[string, number]' of length '2' has no element at index '2'.
}

We can also use JavaScript array deconstruction to Deconstruction tuple.

function doSomething(stringHash: [string, number]) {
  const [inputString, hash] = stringHash;
 
  console.log(inputString);
              ^^^^^^^^^^^    
        // const inputString: string
 
  console.log(hash);
              ^^^^   
            // const hash: number
}

Tuple types are useful in highly convention based API s because the meaning of each element is "explicit". This gives us the flexibility to give variables any name when we deconstruct tuples. In the above example, we can give any name to the elements with subscripts 0 and 1.

However, how can it be "clear"? Every developer has different opinions. Maybe you need to reconsider whether it would be better to use objects with description properties in the API.

In addition to length checking, a simple tuple type like this is actually equivalent to an object that declares the attribute of a specific subscript and contains the length attribute of a numeric literal type.

interface StringNumberPair {
  // Specific properties
  length: 2;
  0: string;
  1: number;
 
  // Other members of array < string | number > type
  slice(start?: number, end?: number): Array<string | number>;
}

Another thing you may be interested in is that tuple types can also have optional elements, just add "after an element type"?. Optional tuple elements can only appear at the end and affect the length of the type.

type Either2dOr3d = [number, number, number?];
 
function setCoordinate(coord: Either2dOr3d) {
  const [x, y, z] = coord;
               ^
            // const z: number | undefined
 
  console.log(`Provided coordinates had ${coord.length} dimensions`);
                                             ^^^^^^                                
                                        // (property) length: 2 | 3
}

Tuples can also use the expansion operator, which must be followed by an array or tuple.

type StringNumberBooleans = [string, number, ...boolean[]];
type StringBooleansNumber = [string, ...boolean[], number];
type BooleansStringNumber = [...boolean[], string, number];
  • Stringnumberboolean represents such a tuple: its first two elements are of type string and number, followed by several elements of type boolean.
  • StringBoolean number represents such a tuple: its first element is of type string, followed by several elements of type boolean, and the last element is number.
  • Boolean stringnumber represents such a tuple: it is preceded by several elements of boolean type, and the last two elements are string and number types respectively.

Tuples that use the expansion operator have no explicit length -- what can be explicit is that they have elements of corresponding types in different positions.

const a: StringNumberBooleans = ["hello", 1];
const b: StringNumberBooleans = ["beautiful", 2, true];
const c: StringNumberBooleans = ["world", 3, true, false, true, false, true];

Why are optional elements and expansion operators useful? Because it allows TypeScript to map parameter lists to tuples. stay Remaining parameters and expansion operators Tuples can be used in, so the following code:

function readButtonInput(...args: [string, number, ...boolean[]]) {
  const [name, version, ...input] = args;
  // ...
}

Is equivalent to this Code:

function readButtonInput(name: string, version: number, ...input: boolean[]) {
  // ...
}

Sometimes, we use the remaining parameters to accept several parameters with variable quantity. At the same time, it is required that the parameters are not less than a certain quantity, and we do not want to introduce intermediate variables. At this time, the above writing method is very convenient.

Read only tuple type

One last thing to note about tuple types is that tuple types can also be read-only. By adding the readonly modifier in front of tuples, we can declare a read-only tuple type - just like the abbreviation of read-only array.

function doSomething(pair: readonly [string, number]) {
  // ...
}

Any property of a read-only tuple cannot be overridden in TypeScript.

function doSomething(pair: readonly [string, number]) {
  pair[0] = "hello!";
       ^
// Cannot assign to '0' because it is a read-only property.
}

In most code, tuples do not need to be modified after they are created, so it is a good default to annotate tuples as read-only types. It is also important that array literals that use const assertions will be inferred as read-only tuples.

let point = [3, 4] as const;
 
function distanceFromOrigin([x, y]: [number, number]) {
  return Math.sqrt(x ** 2 + y ** 2);
}
 
distanceFromOrigin(point);
                 ^^^^^^     
/* 
Argument of type 'readonly [3, 4]' is not assignable to parameter of type '[number, number]'.
  The type 'readonly [3, 4]' is 'readonly' and cannot be assigned to the mutable type '[number, number]'. 
*/

Here, distanceFromOrigin does not modify the element of the tuple, but it expects to accept a variable tuple. Since the type of point is inferred as readonly [3,4], it is incompatible with [number, number], because the latter cannot guarantee that the element of point will not be modified.

Posted by shmony on Wed, 24 Nov 2021 18:34:35 -0800