Published on

Type system tricks in TypeScript

Authors

@Author: Garfield Zhu

Type System

TypeScript has an extremely fancy and strong type system. Let's make good use of it by leveraging the type utilities.


Infer

The keyword infer one of the most powerful keywords for building types, which helps us to extract any type from any nested types.

Example, extract the type list of the parameters of a function

type Param<T extends (...args: any[]) => any> = 
  T extends (...args: infer A)  => any
  ? A
  : never

const foo = (a: number, b: string) => true
const bar = (a: boolean, b: number, c: number) => false
type t1 = Param<typeof foo>   // [number, string]
type t2 = Param<typeof bar>   // [boolean, number, number]


Type Constructor / Type Utilities

TypeScript allows builds new types from old ones and define some type infer to generate types from existing types.

It is a typical feature called Type Constructor in type theory.

Type constructors are really useful to use such type utilities to build more strong and reliable types.

Built-in utilities types

TypeScript contains several useful type utils,

Refer to official utility types.

Example of some really useful ones:

  • Partial<T>

    Given T = { a: string, b: number } is an interface with several parameters. We want to build a type with all props options.

    Then we have Partial<T> = { a?: string, b?: number }

  • Required<T>

    The opposite to Partial

  • Pick<T, K>

    Given T = { a: string, b: number, c: boolean[] }, pick only some props from it.

    Then we can have Pick<T, 'c'> = { c: boolean } Or, Pick<T, 'a', 'b'> = { a: string, b: number }

  • ReturnType<T>

    Get the return type of a function/lambda type.

    Given T = (...args: any[]) => { foo: number, bar: string },

    Then we have ReturnType<T> = { foo: number, bar: string }

  • etc.

Third party extended type utility lib

Custom useful types

With keyword typeof, keyof, infer, ... We can extend and derive almost any types from existing types.

In specific use cases, there will be some cusomized types to resovle problem:

  • React.js

    Typescript helps gooding props typing for React.

    • PropType<T, K>

      Given the props type of a component, get the type of one specific property. (It helps a lot if we require this property in the upper level component)

        /** Given a object type, and a property name, get the type of that object property
         *  Example:
         *    type Foo = { a: number, b: { x: string[], y: (y: string) => boolean }, c: () => void }
         *    type A = PropType<Foo, 'a'>                   // number
         *    type B = PropType<Foo, 'b'>                   // { x: string[], y: (y: string) => boolean }
         *    type C = PropType<Foo, 'c'>                   // () => void
         *    type Bx = PropType<PropType<Foo, 'b'>, 'x'>   // string[]
         *    type By = PropType<PropType<Foo, 'b'>, 'y'>   // (y: string) => boolean
         */ 
        export type PropType<TObj, TProp extends keyof TObj> = TObj[TProp]
      
    • ReactFCPropsType<T, K>

      Givent a third party React component library, we may suffer that the lib does not export the type of the component props but we do need it.

      Defining types in code for 3rd party props may suffer compatibility problem when library updates its interface. We can just infer the type and make it easy to find the type mismatch when compiling.

        /** Given a type of React function component, get the type of the props of it.
         *  It could be used if some 3rd party component does not externally export the props type, but we need it.
         *  (Use `typeof` to assign the type of React.FC component as generic parameter)
         *  Example:
         *    ```
         *      import { Tree, Button } from 'antd'
         *
         *      type TreeProps = ReactFCPropsType<typeof Tree>      // The type of the props required by Tree
         *      type ButtonProps = ReactFCPropsType<typeof Button>  // The type of the props required by Button
         *    ```
         */
        export type ReactFCPropsType<TReactFC> = TReactFC extends (props: infer U, ...args: any[]) => any
          ? U
          : never
      


Covariance & contravariance (协变与逆变)

Covariance and contravariance is what describes how subtyping works in a programming language.

1. A Java problem

See the example below:

  • Is it compilable?
  • Is it runnable?
  • Which line will report error?
  • Which line should be better to found the error?
    public static void f() {
        String[] a = new String[2];
        Object[] b = a;
        a[0] = "hi";
        b[1] = Integer.valueOf(42);
    }
Answer
  public static void f() {
      String[] a = new String[2];
      Object[] b = a;
      a[0] = "hi";
      b[1] = Integer.valueOf(42);  // <--- Runtime exception: java.lang.ArrayStoreException
  }
Why not like this?
  public static void f() {
      String[] a = new String[2];
      Object[] b = a;  // ~~~~~~~ Compile error: "a": String[] could not be assigned to "b": Object[]
      a[0] = "hi";
      b[1] = Integer.valueOf(42);  
  }
It is Object array here, why Object is right?
  public static void f() {
      String a = new String();
      Object b = a;  // Typical polymorphism
      a = "hi";
      b = Integer.valueOf(42);  // Awesome! 
  }

What's the difference?

  • Array has multiple values?
  • b[0] and b[1] cannot have different types?
  • We say "String[] should not be assigned to Object[]", why "assign String to Object" is the core of OOP?
Answer
  • In OOP, we say String is a typically subtype of Object.
  • Assign subtype to super-type is always correct.
  • But, WATCH OUT! String[] is not the subtype of Object[] !

In conclusion, we could say T[A] is subtype of T[B], regardless T's definition but only know A is subtype of B.


2. Covariant, contravariant, bivariant and invariant

Convariance and contravariance are describing the relationship of types after a type calculation.

  • In TypeScript, we use keyword type and generic to construct new types from existing, which is called Type Constructor.

    e.g. type C<T> = T[]

    It happens at compile time only, which totally disappears at JavaScript runtime.

    We say type C defines a collection mapping from T to C<T>.

  • The subtype between such type mapping or constrution C is called "Variance".

    Given collection A is subtype/subset of B, we say A ⊆ B in math:

    Variance is the relationshipt between mapped collections C<A> and C<B>

    In math, we use , to represent the subset/superset relationship. In CS, we use <: for subtyping.

    • Covariance

      It keeps the subset relationship.

      A <: B => C<A> <: C<B>

    • Contravariance

      It reverses the relationship. The original child set is constructed to be superset.

      A <: B => C<A> :> C<B>

    • Biariance

      Both relationship applied.

      A <: B => C<A> <: C<B> AND C<A> :> C<B>

    • Invariance

      No variance anymore after remap.

      A <: B => C<A> ⊄ C<B> AND C<B> ⊄ C<A>


3. Covariance in arrays

Back to the question above, the problem comes to String[] is not subtype of Object[]?

  1. Covariance, contravariance, invariance?

  2. In Java, we see it is covariance. String[] is allowed to be assigned to Object[].

    But obviously, it's not correct when write the array is writtable.

  3. If we say the array is read only, covariance is correct now.

    /* readonly*/ Object[] a = new String[] {"foo", "bar", "test"};
    System.out.println(a[0], a[1], a[2]);
    
Answer
  • A readable and writeable array should be INVARIANT.

    That's why we say String[] is not subtype of Object[]

  • A readonly array is covariant.

    Instead, we can say readonly String[] is a subtype of readonly Object[]

Extension
  • So this is typical static typing problem in Java (as well as C#).

  • Guess Why?

    Root cause
    • Yes, GENERICS. Java and C# does not support generics in old time.

      They use parent typing like the generic bounding to make functions accept more generic types.

        boolean equalArrays (Object[] a1, Object[] a2); // equal function should be readonly, which is safe.
        void shuffleArray(Object[] a);
    
    • It should be defined like this.
        <T extends Comparable<T>> boolean equalArrays (T[] a1, T[] a2);
        <T> void shuffleArray(T[] a);
    
    • Today, the legacy feature is a burden now.

      Use it must take care of if the array is writable to avoid runtime errors.

      Or use some immutable/readonly array instead rather a raw object array. (of course, they introduces overhead before Java/C# introdues raw immutable data type primitives)

      In C# :

      IEnumerable<object> // replace "object[]"
      

      In Java :

      List<Object> items = Collections.unmodifiableList(Arrays.asList("a", "b", "c"));
      

4. Covariance in function typing

The correct behavior for function typing is:

  • The return type is covariant.

    Given A <: B, we have () => A <: () => B.

  • The parameters' types are contravariant.

    Given A <: B, we have (a: A) => void :> (b: B) => void.

  • Above rules work together.

    Given A <: B and C <: D,

    we have (b: B) => C :> (a: A) => D.

See the function stype sample, and React FC sample

TS config strictFunctionType will control if the function parameter is acknowledged as covariance or contravariance.


5. Covariance in inheritance

In OO languages (cpp, Java, C#, etc.), OVERRIDE is key concept in subclass to implement a different method from super class.

We know that overriding should have the same mehtod signature, but it also allows covariance in some languages.

  • Covariant method return type

    In function part, we already know that return type is covariant ( Given A <: B, we have () => A <: () => B. )

    In inheritance, overriding a method with its subtype method is a covariance in heritance. (Java and C++ support this, C# does not)

      class Animal {
        Animal getAnimal() {
          // ...
        }
      }
      
      // Child class
      class Cat extends Animal {
        @overrides
        Cat getAnimal() {
          ...
        }
      }
    
  • Contravariant method parameter type

    Like the above section, we can guess that contravariant in method parameter type is also a type-safe overriding.

    YES. IT IS TYPE SAFE.

    But languages rarely implement it. 😅

    In Java, C++, C#, it will be regarded as overloading instead of a overriding.

    class Animal {
      void setAnimal(Animal a) {
        // ...
      }
    }
    
    // Child class
    class Cat extends Animal {
      // It's still correct. But it's not overriding. It's a overloading in Java.
      // This method could not be hit by a call of "cat.setAnimal(animal)" unless `animal` is not an instance of Animal.
      void setAnimal(Object a) {
        // ...
      }
    }
    

6. Covariance in generic

There are two main approaches for generic type:

  • Declaration-site variance annotations (C#)
  • Use-site variance annotations (Java)

Conclude from the above sections, we may found that:

  1. The generic for type of input parameters, should be covariant.
  2. The generic for type of output parameters, should be contravariant.
Declaration-site variance annotations

C# uses keyword in(covariant) and out(contravariant) to mark the types.

interface IEnumerator<out T>
{
    T Current { get; }
    bool MoveNext();
} 

It will report error when declare the interface with using out T as type of an input parameter.

Scala uses +(covariant) and -(contravariant) as keywords.

sealed abstract class List[+A] extends AbstractSeq[A] {
    def head: A
    def tail: List[A]

    /** Adds an element at the beginning of this list. */
    def ::[B >: A] (x: B): List[B] =
        new scala.collection.immutable.::(x, this)
    /** ... */
}
Use-site variance annotations

It checks the variance covariance when generic type is instantiated.

Given type A<T> = T, it should reports error when instantiated a A<Test> when Test does not match T's requirement.

A typical implementation is "upper/lower boundary constraints"

  1. In Java

    We have bound descriptor extends and super in Java

    // Lower bounds is very common in the languages support generic
    List<? extends Animal>
    // Upper bounds is not common, Java uses "super" keyword
    List<? super Animal>
    
  2. In Typescript

    Up-to-date, Typescript does not have a upper-bound generic type contraints yet. The open issue is: TypeScript#9252

    However, with the existing TS type utilies, we have a workaround to support upper boundary: say Partial<T>

    See the case discussed: here, think:

    Partial<T> is equivalent with <S super T> ?


7. Samples

  • In React component React functional component is recommended. The variance of React props should be understood to avoid unnecessary problems.

    See the example below in real practice, where is the problem?

    // React component definition:
    type Elem = {
      id: string;
    }
    type Props = {
        elem: Elem;
        generate: () => Elem;
        onClick: (elem: Elem) => void;
    }
    
    // React FC
    const MyComp: React.FC<Props> = (props: Props) => {
      const { elem, onClick, }
      const guiElement = {
        ...elem,
        // Extended GUI properties
        name: `element - ${elem.id}`,
        desc: `description for - ${elem.id}`,
      }
      
      // Event handler callback
      const clickHandler = React.useCallback((_e) => {
        console.log(generate())
        onClick(guiElement)
      }, [])
    
      return <button onClick={clickHandler}>
        {guiElement.id}
      </button>
    }
    
    
    // Use the above component
    const customElem = {
      id: '0hd3ga1fa3h2664g',
      count: 99, 
      name: 'bar',
    }
    
    const g = () => customElem
    
    const cb = (e: typeof customElem) => {
      alert((e as any).name)     // ?
      alert(JSON.stringify(e))   // ?
      alert(e.name.split(' '))   // ?
    }
    
    ReactDOM.render(<MyComp 
      elem={customElem} // is this correct?
      generate={g}      // is this correct?
      onClick={cb}      // is this correct?
    />)
    

    Simplify it, look at:

    Sample on playground

  • Sub union as React props in TypeScript Given a React component requires a prop with a union type: A | B | C, the user uses the component provides an instance with type of the narrower union type: A | C. Is this safe?

    type Props = { bar: A | B | C } 
    const Foo: React.FC<Props> = (props) => {
      // ... do with props.bar
    }
    
    // use case:
    type TestType = A | C
    const myBar: TestType
    
    render(<Foo bar={myBar} />) // ?? Type safe
    
    
  • Remember runtime features will corrupt your static typing.

    Like Reflection, it will make the type inference missing at where the reflection begins.

    Reflection is the concept occurs in Java and C#. For dynamic language, it is more common being used naturally with literal objects.

    Like for (const key in obj) ...

    It is really useful, but it is really a runtime feature and heavily breaks static typing system. Remember use it where you understand and be careful with typing.

    Example:

    // Base type
    type Base = {
      id: string;
      name: string;
    }
    
    // literal object derives the `Base`
    const obj: Base = {
      id:       'abc',
      name:     'test',
      category: 'foo',
      item:     'bar',
    }
    
    // JSON stringify method will iterate the runtime instance of `obj`, instead of the part of `Base`
    const json = JSON.stringify(obj)
    // JSON.parse is also runtime method, which makes us lost typing information.
    const restored = JSON.parse(json) /* as Base  */ 
    

Tricks

Interfaces with excess properties

TypeScript lets us pass { size: number; label: string; } to something that only expected a { label: string; }, since TS has a structural subtyping system. But inline literal objects does not.

It is caused by excess properties check,

Play with the sample

Classes (nominal typing)

Also caused by structural typing. If class A and class B share the same members, the functions which require an instance of A, also accepts B as legal parameter.

Try the sample

Discriminated Unions

TypeScript has a feature called discriminated unions. Quoting from the docs, there are 2 requirements for discriminated unions:

  • Types that have a common, singleton type property — the discriminant.
  • A type alias that takes the union of those types — the union.

It means in case:

interface Dog {
    kind: "dog"
    bark: string
}

interface Cat {
    kind: "cat"
    meow: string
}

type Animal = Cat | Dog

The type Animal is expected have property kind on it.

However, it does not work if the common property is a nested object, like:

interface Dog {
    taxonomy: {
        species: "Canis familiaris"
    }
    bark: string
}

interface Cat {
    taxonomy: {
        species: "Felis catus"
    }
    meow: string
}

Play with the sample

References