- Published on
Type system tricks in TypeScript
- Authors
- Name
- Garfield Zhu
- @_AlohaYo_
@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
Some very cool type utility which helps a lot. Some are now part of TypeScript built-in.
Especially some Flow's Utility Types, which brings those useful stuffs from Flow's.
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]
andb[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 ofB
, we sayA ⊆ B
in math:Variance is the relationshipt between mapped collections
C<A>
andC<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>
ANDC<A> :> C<B>
Invariance
No variance anymore after remap.
A <: B
=>C<A> ⊄ C<B>
ANDC<B> ⊄ C<A>
3. Covariance in arrays
Back to the question above, the problem comes to String[]
is not subtype of Object[]
?
Covariance, contravariance, invariance?
In Java, we see it is covariance.
String[]
is allowed to be assigned toObject[]
.But obviously, it's not correct when write the array is writtable.
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 ofObject[]
A readonly array is covariant.
Instead, we can say
readonly String[]
is a subtype ofreadonly 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
andC
<: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:
- The generic for type of input parameters, should be covariant.
- 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"
In Java
We have bound descriptor
extends
andsuper
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>
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:
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