May 07, 2021 TypeScript
A cross-type is a combination of multiple types into one type. T
his allows us to overlay the existing multiple types into a type that contains all the types of features we need. F
or example,
Person & Serializable & Loggable
both
Person
and
Serializable
and
Loggable
This means that this type of object has all three types of members at the same time.
Most of us see the use of crossover types in mixins or other places that are not suitable for typical object-oriented models. ( There are a lot of occasions when this happens in JavaScript!) Here's a simple example of how to create a blend:
function extend<T, U>(first: T, second: U): T & U {
let result = <T & U>{};
for (let id in first) {
(<any>result)[id] = (<any>first)[id];
}
for (let id in second) {
if (!result.hasOwnProperty(id)) {
(<any>result)[id] = (<any>second)[id];
}
}
return result;
}
class Person {
constructor(public name: string) { }
}
interface Loggable {
log(): void;
}
class ConsoleLogger implements Loggable {
log() {
// ...
}
}
var jim = extend(new Person("Jim"), new ConsoleLogger());
var n = jim.name;
jim.log();
Union types are associated with cross-types, but they are used completely differently. O
ccasionally you encounter this situation where a code base wants to pass in
number
string
or string.
For example, the following function:
/**
* Takes a string and adds "padding" to the left.
* If 'padding' is a string, then 'padding' is appended to the left side.
* If 'padding' is a number, then that number of spaces is added to the left side.
*/
function padLeft(value: string, padding: any) {
if (typeof padding === "number") {
return Array(padding + 1).join(" ") + value;
}
if (typeof padding === "string") {
return padding + value;
}
throw new Error(`Expected string or number, got '${padding}'.`);
}
padLeft("Hello world", 4); // returns " Hello world"
There is a problem with
padLeft
and the type of
padding
parameter is specified
any
This means that we can pass in a parameter that is
number
string
type, but TypeScript is not wrong.
let indentedString = padLeft("Hello world", true); // 编译阶段通过,运行时报错
In traditional object-oriented languages, we might abstract these two types into hierarchical types. T
his is obviously very clear, but there is also over-design. O
ne of the benefits of the original
padLeft
version is that it allows us to pass in the original type. T
his is simple and convenient to use.
This new approach doesn't apply if we just want to use functions that already exist.
Instead
any
we
can use the union
type as a parameter for
padding
/**
* Takes a string and adds "padding" to the left.
* If 'padding' is a string, then 'padding' is appended to the left side.
* If 'padding' is a number, then that number of spaces is added to the left side.
*/
function padLeft(value: string, padding: string | number) {
// ...
}
let indentedString = padLeft("Hello world", true); // errors during compilation
A union type represents a value that can be one of several types. W
e use a vertical bar
|
Separates each type, so
number | string | boolean
that can be
number
string
or
boolean
If a value is a union type, we can only access members that are common in all types of this union type.
interface Bird {
fly();
layEggs();
}
interface Fish {
swim();
layEggs();
}
function getSmallPet(): Fish | Bird {
// ...
}
let pet = getSmallPet();
pet.layEggs(); // okay
pet.swim(); // errors
The type of union here may be a bit complicated, but it's easy to get used to it. I
f the type of a value is
A | B
what we
can be
sure of is that it
A
that
are common in A and
B
I
n this example,
Bird
has a
fly
member. W
e can't be
Bird | Fish
of type Fish has
fly
method.
If the variable is a
Fish
runtime, calling
pet.fly()
is an error.
Union types are ideal for situations where there are different types of values that can be received. W
hat do we do when we want to know for sure if we get
Fish
T
he common way to distinguish between two possible values in JavaScript is to check for their existence.
As mentioned earlier, we can only access members that are common in all types of federation types.
let pet = getSmallPet();
// 每一个成员访问都会报错
if (pet.swim) {
pet.swim();
}
else if (pet.fly) {
pet.fly();
}
For this code to work, we'll use type assertions:
let pet = getSmallPet();
if ((<Fish>pet).swim) {
(<Fish>pet).swim();
}
else {
(<Bird>pet).fly();
}
You can note that we used type assertions multiple times.
If only we had checked the type once, we would have been able to figure out the
pet
in each branch that followed.
The type protection mechanism in TypeScript makes it a reality. T ype protection is an expression that checks at runtime to make sure that the type is in a scope. To define a type protection, we simply define a function whose return value is a type assertion:
function isFish(pet: Fish | Bird): pet is Fish {
return (<Fish>pet).swim !== undefined;
}
In this example,
pet is Fish
type predicate.
The predicate
parameterName is Type
and the
parameterName
must be a parameter name from the current function signature.
Whenever isFish is called
isFish
TypeScript reduces the variable to that specific type, as long as it is compatible with the original type of the variable.
// 'swim' 和 'fly' 调用都没有问题了
if (isFish(pet)) {
pet.swim();
}
else {
pet.fly();
}
Note that TypeScript not only knows
Fish
if
pet is
else
a Fish type
Fish
in the
pet
Bird
typeof
type protection
Now let's go back and see how the
padLeft
federation type.
We can write using type assertions like this:
function isNumber(x: any): x is number {
return typeof x === "number";
}
function isString(x: any): x is string {
return typeof x === "string";
}
function padLeft(value: string, padding: string | number) {
if (isNumber(padding)) {
return Array(padding + 1).join(" ") + value;
}
if (isString(padding)) {
return padding + value;
}
throw new Error(`Expected string or number, got '${padding}'.`);
}
However, it is painful to have to define a function to determine whether the type is the original type. F
ortunately, we
typeof x === "number"
into a function now, because TypeScript can recognize it as a type protection.
That means we can check the type directly in the code.
function padLeft(value: string, padding: string | number) {
if (typeof padding === "number") {
return Array(padding + 1).join(" ") + value;
}
if (typeof padding === "string") {
return padding + value;
}
throw new Error(`Expected string or number, got '${padding}'.`);
}
There are
typeof
forms of type protection that can be identified:
typeof v === "typename"
and
typeof v !== "typename"
must
"number"
"string"
"boolean"
"symbol"
"typename"
But TypeScript doesn't prevent you from comparing with other strings, and languages don't recognize those expressions as type protection.
instanceof
type protection
If you've
typeof
type protection and are familiar with
instanceof
you might have guessed what this section says.
instanceof
protection is
a way to refine types by constructors.
For example, let's take a look at an example of a previous string fill:
interface Padder {
getPaddingString(): string
}
class SpaceRepeatingPadder implements Padder {
constructor(private numSpaces: number) { }
getPaddingString() {
return Array(this.numSpaces + 1).join(" ");
}
}
class StringPadder implements Padder {
constructor(private value: string) { }
getPaddingString() {
return this.value;
}
}
function getRandomPadder() {
return Math.random() < 0.5 ?
new SpaceRepeatingPadder(4) :
new StringPadder(" ");
}
// 类型为SpaceRepeatingPadder | StringPadder
let padder: Padder = getRandomPadder();
if (padder instanceof SpaceRepeatingPadder) {
padder; // 类型细化为'SpaceRepeatingPadder'
}
if (padder instanceof StringPadder) {
padder; // 类型细化为'StringPadder'
}
instanceof
requirement of instanceof is a constructor that TypeScript will refine to:
prototype
of this constructor, if its type
any
In this order.
A type alias gives a new name to a type. Type aliasses are sometimes similar to interfaces, but can work on raw values, union types, yuans, and any other type you need to write by hand.
type Name = string;
type NameResolver = () => string;
type NameOrResolver = Name | NameResolver;
function getName(n: NameOrResolver): Name {
if (typeof n === 'string') {
return n;
}
else {
return n();
}
}
The alias does not create a new type - it creates a new name to reference that type. Aliasing the original type is usually of little use, although it can be used as a form of documentation.
Like interfaces, type aliases can also be generic - we can add type parameters and pass in to the right side of the alias declaration:
type Container<T> = { value: T };
We can also use type alias to refer to ourselves in a property:
type Tree<T> = {
value: T;
left: Tree<T>;
right: Tree<T>;
}
However, the type alias does not appear on the right side of the notable statement:
type LinkedList<T> = T & { next: LinkedList<T> };
interface Person {
name: string;
}
var people: LinkedList<Person>;
var s = people.name;
var s = people.next.name;
var s = people.next.next.name;
var s = people.next.next.next.name;
However, the type alias cannot appear anywhere to the right of the declaration.
type Yikes = Array<Yikes>; // error
As we mentioned, type aliases can be like interfaces;
First, the interface creates a new name that can be used anywhere else. T
ype aliases do not create new names -- for example, error messages do not use aliases.
In the following sample code, hover over
interfaced
showing that it
aliased
it shows the literal type of object.
Interface
type Alias = { num: number }
interface Interface {
num: number;
}
declare function aliased(arg: Alias): Alias;
declare function interfaced(arg: Interface): Interface;
Another important difference is that type alias cannot be
extends
implements
(you cannot
extends
implements
yourself).
Because
objects in the software should be open to extensions, but for modifications that are closed,
you should try to use interfaces instead of type aliases.
On the other hand, if you can't describe a type through an interface and you need to use a union type or a group type, you typically use a type alias.
String literal types allow you to specify the fixed values that strings must have. I n practice, string literal types work well with union types, type protection, and type aliases. By combining these attributes, you can implement strings of similar enumeration types.
type Easing = "ease-in" | "ease-out" | "ease-in-out";
class UIElement {
animate(dx: number, dy: number, easing: Easing) {
if (easing === "ease-in") {
// ...
}
else if (easing === "ease-out") {
}
else if (easing === "ease-in-out") {
}
else {
// error! should not pass null or undefined.
}
}
}
let button = new UIElement();
button.animate(0, 0, "ease-in");
button.animate(0, 0, "uneasy"); // error: "uneasy" is not allowed here
You can only select one of the three allowed characters as an argument pass, and passing in other values results in an error.
Argument of type '"uneasy"' is not assignable to parameter of type '"ease-in" | "ease-out" | "ease-in-out"'
String literal types can also be used to distinguish function overloads:
function createElement(tagName: "img"): HTMLImageElement;
function createElement(tagName: "input"): HTMLInputElement;
// ... more overloads ...
function createElement(tagName: string): Element {
// ... code goes here ...
}
You can combine string literal types, union types, type protection, and type aliases to create an advanced pattern called identifiable federation, also known as tag federation or algegegelia data types. I dentifiable union is useful in functional programming. S ome languages automatically identify federations for you, while TypeScript is based on existing JavaScript patterns. It has four elements:
interface Square {
kind: "square";
size: number;
}
interface Rectangle {
kind: "rectangle";
width: number;
height: number;
}
interface Circle {
kind: "circle";
radius: number;
}
First we declare the interface that will be federationd. E
ach interface has
kind
property but has a different literal type of character.
kind
property is
called a recognizable feature
or
label.
O
ther properties are specific to each interface. N
ote that there is currently no connection between the interfaces.
Let's put them together:
type Shape = Square | Rectangle | Circle;
Now let's use identifiable union:
function area(s: Shape) {
switch (s.kind) {
case "square": return s.size * s.size;
case "rectangle": return s.height * s.width;
case "circle": return Math.PI * s.radius ** 2;
}
}
When not all identifiable union changes are covered, we want the compiler to notify us.
For example, if we add
Triangle
Shape
we also need to update
area
:
type Shape = Square | Rectangle | Circle | Triangle;
function area(s: Shape) {
switch (s.kind) {
case "square": return s.size * s.size;
case "rectangle": return s.height * s.width;
case "circle": return Math.PI * s.radius ** 2;
}
// should error here - we didn't handle case "triangle"
}
There are two ways to do this.
The first
--strictNullChecks
and specify a return value type:
function area(s: Shape): number { // error: returns number | undefined
switch (s.kind) {
case "square": return s.size * s.size;
case "rectangle": return s.height * s.width;
case "circle": return Math.PI * s.radius ** 2;
}
}
Because
switch
does not cover all cases, TypeScript believes that this function sometimes
undefined
I
f you explicitly specify a return value type of
number
you will see an error because the type of return value
number | undefined
However, there are some subtleties to
--strictNullChecks
support the old code well.
The second method uses the
never
which the compiler uses for integrity checks:
function assertNever(x: never): never {
throw new Error("Unexpected object: " + x);
}
function area(s: Shape) {
switch (s.kind) {
case "square": return s.size * s.size;
case "rectangle": return s.height * s.width;
case "circle": return Math.PI * s.radius ** 2;
default: return assertNever(s); // error here if there are missing cases
}
}
Here,
assertNever
s
the
never
-- that is, the type that remains after all possible cases have been removed. I
f you forget a case,
s
have a catch-up type, so you'll get an error.
This approach requires you to define an additional function.
this
type
The polymorphic
this
type represents a sub-type that contains a
class or interface.
T
his is
called F-bounded polymorphism. I
t can easily be used to perform inheritance between coherent interfaces, for example.
In the calculator's example, the this type is returned
this
operation:
class BasicCalculator {
public constructor(protected value: number = 0) { }
public currentValue(): number {
return this.value;
}
public add(operand: number): this {
this.value += operand;
return this;
}
public multiply(operand: number): this {
this.value *= operand;
return this;
}
// ... other operations go here ...
}
let v = new BasicCalculator(2)
.multiply(5)
.add(1)
.currentValue();
Since this class uses
this
you can inherit it, and the new class can use the previous method directly without any changes.
class ScientificCalculator extends BasicCalculator {
public constructor(value = 0) {
super(value);
}
public sin() {
this.value = Math.sin(this.value);
return this;
}
// ... other operations go here ...
}
let v = new ScientificCalculator(2)
.multiply(5)
.sin()
.add(1)
.currentValue();
Without
this
ScientificCalculator
be able to inherit
BasicCalculator
while maintaining interface consistency.
multiply
return
BasicCalculator
which does not have
sin
method.
However,
this
type,
multiply
this
and here
ScientificCalculator