TypeScript type security

Posted by gordonmc on Mon, 13 Dec 2021 03:05:34 +0100

preface

There are many places in TypeScript that involve the concepts of subtype, supertype, Covariant, Contravariant, bidirectional Covariant and Invariant. If you don't understand these concepts, you may be reported as wrong and have no way to start, or you can see others write like this when writing some complex types, But I don't know why.

extends keyword

In TypeScript, the extends keyword has the following three meanings in different application scenarios:

  1. Meaning of inheritance / expansion:

Inherit the methods and properties of the parent class

class Animal {
  public weight: number = 0
  public age: number = 0
}

class Dog extends Animal {
  public wang() {
    console.log('Woof!')
  }
  public bark() {}
}

class Cat extends Animal {
  public miao() {
    console.log('Meow~')
  }
}

Inheritance type

interface Animal {
  age: number
}

interface Dog extends Animal {
  bark: () => void
}
 // Dog => { age: number; bark(): void }
  1. Indicates the meaning of the constraint

When writing generics, we often need to limit the type parameters. For example, if we want the passed parameters to have an array with name attribute, we can write this:

function getCnames<T extends { name: string }>(entities: T[]):string[] {
  return entities.map(entity => entity.cname)
}
  1. Indicates the meaning of assignment (assignable)
type Animal = {
  name: string;
}
type Dog = {
  name: string;
  bark: () => void
}
type Bool = Dog extends Animal ? 'yes' : 'no'; // 'yes'

The following focuses on some usage of assignment meaning, that is, assignability

Simple value matching

type Equal<X, Y> = X extends Y ? true : false;

type Num = Equal<1, 1>; // true
type Str = Equal<'a', 'a'>; // true
type Boo1 = Equal<true, false>; // false
type Boo2 = Equal<true, boolean>; // true

Type X can be assigned to type Y, rather than saying that type X is a subset of type y

never

Some examples of its natural assignment:

  • A function that never returns a value (for example, if the function contains while(true) {});
  • A function that always throws errors (for example, function foo() {throw new error ('not implemented ')}, and the return type of foo is never);

never is a subtype of all types

type A = never extends 'x' ? string : number; // string

type P<T> = T extends 'x' ? string : number;
type B = P<never> // string

Complex type value matching

class Animal {
  public weight: number = 0
  public age: number = 0
}

class Dog extends Animal {
  public wang() {
    console.log('wang')
  }
  public bark() {}
}

class Cat extends Animal {
  public miao() {
    console.log('miao')
  }
}

type Equal<X, Y> = X extends Y ? true : false;
type Boo = Equal(Dog, Animal) // true
type Boo = Equal(Animal, Dog) // false

type Boo = Equal(Animal, Dog) // false this is because Animal does not have a bark attribute, and type Animal does not satisfy the type constraint of type Dog. Therefore, A extends B means that type a can be assigned to type B, rather than that type A is a subset of type B. It is very important to understand the usage of extends in type ternary expressions.

Parent child type

Or animal analogy:

interface Animal {
  age: number
}

interface Dog extends Animal {
  bark: () => void
}

let animal: Animal
let dog: Dog

In this example, Animal is the parent class of Dog, and Dog is the subtype of Animal. The properties of the subtype are more specific than the parent type.

  • In the type system, more types of attributes are subtypes.
  • In set theory, sets with fewer attributes are subsets.

In other words, subtypes are supersets of parent types, and parent types are subsets of subtypes, which is intuitively confusing.

It is critical to remember that a subtype is more specific than a parent.

As can be seen from the above example, animal is a "broader" type with fewer attributes, so more "specific" subtypes can be assigned to it, because you know that age is the only attribute on animal, you will only use this attribute, and dog has all types owned by animal, Assigning to animal will not cause type safety problems.

On the contrary, if dog = animal, the subsequent user will expect the dog to have the bark attribute when he calls dog Bark () will cause a runtime crash.

From the perspective of assignability, subtypes can be assigned to the parent type, that is, parent type variables = subtype variables are safe, because subtypes cover all the attributes owned by the parent type.

When I first learned it, I would find it strange that statements such as T extends {} can extend an empty type and hold true when passing any type? When we understand the above knowledge points, this problem will be solved naturally.

So far, I have basically finished talking about the three uses of extends, and now I come to the main topic: inverse covariance, bidirectional covariance and invariance

origin

ts has been written for a long time. Once, when writing props type for a component, you need to pass an onClick time function type, a problem suddenly comes to mind:

Why are function types defined in the interface written as function attributes rather than methods, that is:

interface Props {
  handleClick: (arg: string) => number   // Universal writing
  handleClick(arg: string): number  // Non mainstream writing
}

Finally, I encountered this rule when I saw the rule set in typescript eslint

@typescript-eslint/method-signature-style

The rule cases are as follows:

❌ Incorrect

interface T1 {
  func(arg: string): number;
}
type T2 = {
  func(arg: boolean): void;
};
interface T3 {
  func(arg: number): void;
  func(arg: string): void;
  func(arg: boolean): void;
}

✅ Correct

interface T1 {
  func: (arg: string) => number;
}
type T2 = {
  func: (arg: boolean) => void;
};
// this is equivalent to the overload
interface T3 {
  func: ((arg: number) => void) &
    ((arg: string) => void) &
    ((arg: boolean) => void);
}

A method and a function property of the same type behave differently. Methods are always bivariant in their argument, while function properties are contravariant in their argument under strictFunctionTypes.

Methods and function properties of the same type behave differently. Methods are always bivariate in their parameters, while function attributes are inverse in parameters under strict function types.

After seeing this sentence, I also looked confused. I saw the two words of two-way covariance and inversion for the first time, so I consulted the data to find their concepts and extended covariance and invariance

Inverse covariance

First paragraph Definition of Wikipedia:

Covariance and contravariance is a term used in computer science to describe whether there is a parent / child type relationship between multiple types with parent / child type relationship and multiple complex types constructed through type constructors.

Eh, the parent / child type relationship seems to have been mentioned earlier, and then the inversion and covariance, as well as the allocability mentioned earlier. That's why we spend a lot of time introducing the extends keyword at the beginning of the article. Determining the allocability between types in ts is based on structural typing

Covariance

So imagine that now we have these two subtype arrays respectively. What should be the parent-child relationship between them? Yes, Animal [] is still the parent type of Dog []. For such a piece of code, it is still safe to assign a subtype to the parent type:

let animals: Animal[]
let dogs: Dog[]

animals = dogs

animals[0].age // ✅ok

After being transformed into an array, we will still only look for the attributes that must exist in the Dog type for the variables of the parent type (because the subtype is more specific, and all the attributes of the parent type have subtypes)

Then, for the type constructor type makearray < T > = t [], it is Covariance.

Contravariance

Inversion is really difficult to understand. First, make an interesting (ChAT) question (source: in-depth understanding of TypeScript)

Before starting the topic, we agree on the following marks:

  • A ≼ B means that a is a subtype of B.
  • A → B refers to the function type with a as the parameter type and B as the return value type.
  • x: A means that the type of x is A.

Question: which of the following types is a subtype of Dog → Dog?

  1. Greyhound → Greyhound
  2. Greyhound → Animal
  3. Animal → Animal
  4. Animal → Greyhound

Let's think about how to answer this question. First, we assume that f is a function with Dog → Dog as the parameter. Its return value is not important. In order to specifically describe the problem, we assume that the function structure is as follows: F: (Dog → Dog) → String.

Now I want to pass in some function g to function f to call. Let's see what happens when G is of the above four types.

1. We assume that G: Greyhound → Greyhound, is the type of f(g) safe?

Unsafe, because when calling its parameter (g) function in f, the parameter used may be a subtype different from greyhound but a dog, such as German shepherd.

2. We assume that the type of G: Greyhound → Animal, f(g) is safe?

unsafe. The reason is the same as (1).

3. We assume that G: Animal → Animal, is the type of f(g) safe?

unsafe. Because f it is possible to let the return value, that is, Animal dog bark, after calling the parameter. Not all animals bark.

4. We assume that G: animal → Greyhound, is the type of f(g) safe?

Yes, its type is safe. First, f may be called with any dog breed as a parameter, and all dogs are animals. Second, it may assume that the result is a dog and that all greyhounds are dogs.

That is, after calling the following type constructors for types:

type MakeFunction<T> = (arg: T) => void

The parent-child type relationship is reversed (understood by the above question: Animal → Greyhound is the subtype of Dog - > Dog, but Animal is the parent type of Dog). This is Contravariance.

Through this example, it can be found that:

  • Return value - > covariance (Greyhound - > dog)
  • Input parameters should usually be inverse (animal < - Dog)

Function properties and function methods

After understanding these two concepts, we can roughly guess the definitions of two-way covariance and invariance. Two-way covariance can be both covariant and invertible. On the contrary, it can neither be covariant nor invertible. Now let's go to the previous confusion: why is it recommended to use the writing of function attributes to define function types in interface Props {}?

First understand a knowledge point: what is the difference between the two writing methods?

// Try in tsconfig TS = > compileroptions = > Add / remove the rule "strictFunctionTypes": true. View the following results respectively
interface Animal {
  name: string
}

interface Dog extends Animal {
  wang: () => void
}

interface Cat extends Animal {
  miao: () => void
}

// This can be compared with the Props interface definition
interface Comparer<T> {
  compare(a: T, b: T): number
  // compare: (a: T, b: T) => number;
}

declare let animalComparer: Comparer<Animal>
declare let dogComparer: Comparer<Dog>

animalComparer = dogComparer // ???
dogComparer = animalComparer // Ok

Let's take a look at the difference between the two results compiled into js?

class Handler {
  constructor() {
    this.handleDebounce();
    this.handleThrottle();
  }

  handleDebounce() {
    console.log('Anti shake function', this);
  }

  handleThrottle = () => {
    console.log('Throttling function', this);
  }
}

let handler = new Handler();

Compile.... =>

var Handler = /** @class */ (function () {
    function Handler() {
        var _this = this;
        this.handleThrottle = function () {
            console.log('Throttling function', _this);
        };
       
        this.handleDebounce();
        this.handleThrottle();
    }

    Handler.prototype.handleDebounce = function () {
        console.log('Anti shake function', this);
    };

    return Handler;
}());

var handler = new Handler();

It can be seen that handledebunce is written on the prototype object, and the general methods of the native object are also written on the prototype. In fact, this behavior of typescript is to be compatible with js behavior and take into account the development experience.

Reuse official The following two examples illustrate this problem again:

declare let f1: (x: Animal) => void;
declare let f2: (x: Dog) => void;
declare let f3: (x: Cat) => void;
f1 = f2;  // Error with --strictFunctionTypes
f2 = f1;  // Ok
f2 = f3;  // Error

The first assignment is allowed in the default type checking mode, but is marked as an error in the strict function type mode. Intuitively, the default mode allows assignment because it may be reasonable, while the strict function type mode makes it an error because it cannot be proved to be reasonable. In either mode, the third assignment is wrong because it will never be reasonable.

Another way to describe the example is that the type is bivariate (i.e. covariant or inverse) t in the default type check mode (X: T) = > void, but inverse t in the strict function type mode.

interface Comparer<T> {
    compare(a: T, b: T): number;
}

declare let animalComparer: Comparer<Animal>;
declare let dogComparer: Comparer<Dog>;

animalComparer = dogComparer;  // Ok because of bivariance
dogComparer = animalComparer;  // Ok

In -- strictFunctionTypesmode, the first assignment is still allowed because it compares is declared as a method. In fact, t is bivariate, comparer < T > because it is only used for method parameter positions. However, changing compare to a property with a function type will result in more stringent checks taking effect:

interface Comparer<T> {
    compare: (a: T, b: T) => number;
}

declare let animalComparer: Comparer<Animal>;
declare let dogComparer: Comparer<Dog>;

animalComparer = dogComparer;  // Error
dogComparer = animalComparer;  // Ok

Conclusion: in strict mode (or strict function types): type safety will be guaranteed. On the contrary, the default two-way covariance may make you unsafe when using types!

Array

First throw a question: can list < dog > be a subtype of list < animal >? (source: understanding TypeScript in depth)

Let's take a look at the following example:

interface Animal {
  name: string
}

interface Dog extends Animal {
  wang: () => void
}

interface Cat extends Animal {
  miao: () => void
}

const dogs: Array<Dog> = []
const animals: Animal[] = dogs
// Array is bidirectional covariant in ts
animals.push(new Cat())

If the list is immutable, the answer is yes, because the type is safe. But if the list is variable, the answer is definitely no!

Variable data

If you look at the Array type of typescript, you can see that the Array type definition writes function methods. Therefore, its input parameters are bidirectional covariant!

interface Array<T> {
    length: number;
    toString(): string;
    toLocaleString(): string;
    pop(): T | undefined;
    push(...items: T[]): number;
    concat(...items: ConcatArray<T>[]): T[];
    concat(...items: (T | ConcatArray<T>)[]): T[];
    join(separator?: string): string;
    reverse(): T[];
    shift(): T | undefined;
    slice(start?: number, end?: number): T[];
    sort(compareFn?: (a: T, b: T) => number): this;
    splice(start: number, deleteCount?: number): T[];
    splice(start: number, deleteCount: number, ...items: T[]): T[];
    unshift(...items: T[]): number;
    indexOf(searchElement: T, fromIndex?: number): number;
    lastIndexOf(searchElement: T, fromIndex?: number): number;
    every(callbackfn: (value: T, index: number, array: T[]) => boolean, thisArg?: any): boolean;
    some(callbackfn: (value: T, index: number, array: T[]) => boolean, thisArg?: any): boolean;
    forEach(callbackfn: (value: T, index: number, array: T[]) => void, thisArg?: any): void;
    map<U>(callbackfn: (value: T, index: number, array: T[]) => U, thisArg?: any): U[];
    filter<S extends T>(callbackfn: (value: T, index: number, array: T[]) => value is S, thisArg?: any): S[];
    filter(callbackfn: (value: T, index: number, array: T[]) => any, thisArg?: any): T[];
    reduce(callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T): T;
    reduce(callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T, initialValue: T): T;
    reduce<U>(callbackfn: (previousValue: U, currentValue: T, currentIndex: number, array: T[]) => U, initialValue: U): U;
    reduceRight(callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T): T;
    reduceRight(callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T, initialValue: T): T;
    reduceRight<U>(callbackfn: (previousValue: U, currentValue: T, currentIndex: number, array: T[]) => U, initialValue: U): U;
    [n: number]: T;
}

Variable array + bidirectional covariance cannot guarantee type safety

Safer array types

interface MutableArray<T> {
        length: number;
    toString(): string;
    toLocaleString(): string;
    pop: () => T | undefined;
    push: (...items: T[]) =>  number;
    concat:(...items: ConcatArray<T>[]) => T[];
    join: (separator?: string) => string;
    reverse: () => T[];
    shift:() => T | undefined;
    slice:(start?: number, end?: number) => T[];
    sort:(compareFn?: (a: T, b: T) => number) => this;
    indexOf: (searchElement: T, fromIndex?: number) => number;
      // ...
}

At this point, we will find that MutableArray is actually an immutable type and can no longer be allocated to each other

const dogs: MutableArray<Dog> = [] as Dog[];
// error
const animals: MutableArray<Animal> = dogs;

const animals: MutableArray<Animal> = [] as Animal[] ;
// error
const dogs: MutableArray<Dog> = animals

The reason is that the Array type has both the inverse method push and the covariant method pop, and even the immutable method concat

summary

  • You can use readonly to mark attributes so that they are immutable
  • Use function properties more than function methods to define types
  • Try to separate covariances or inversions in types, or make types immutable
  • Avoid two-way covariance as much as possible

reference material

[1]@typescript-eslint/method-signature-style: https://github.com/typescript...

[2]PR: https://github.com/microsoft/...

Topics: TypeScript