Primitive Types#
const name: string = 'linbudu';
const age: number = 24;
const male: boolean = false;
const undef: undefined = undefined;
const nul: null = null;
const obj: object = { name, age, male };
const bigintVar1: bigint = 9007199254740991n;
const bigintVar2: bigint = BigInt(9007199254740991);
const symbolVar: symbol = Symbol('unique');
null and undefined#
In JavaScript, null and undefined represent "there is a value, but it is empty" and "there is no value", respectively. In TypeScript, both null and undefined types are meaningful types. This means that when they are used as types, they represent a specific type value that has meaning. When strictNullChecks
is not enabled, both are considered subtypes of other types, for example, the string type is considered to include null and undefined types:
const tmp1: null = null;
const tmp2: undefined = undefined;
const tmp3: string = null; // Only valid when strictNullChecks is off, same below
const tmp4: string = undefined;
void#
TypeScript also has a void primitive type, but unlike JavaScript, here void is used to describe the return value of a function that does not have a return statement internally or does not explicitly return a value, such as:
function func1() {}
function func2() {
return;
}
function func3() {
return undefined;
}
Here, the return types of func1 and func2 will be implicitly inferred as void, while only func3, which explicitly returns undefined, will have its return type inferred as undefined. However, in actual code execution, both func1 and func2 return undefined.
Although func3's return type will be inferred as undefined, you can still use void type for annotation, because at the type level, func1, func2, and func3 all represent "not returning a meaningful value".
This might be a bit convoluted; you can think of void as representing an empty type, while null and undefined are meaningful actual types (note the distinction from their meanings in JavaScript). Undefined can be assigned to a void type variable, just like in JavaScript, a function with no return value will default to returning undefined. The null type can also be assigned, but only when strictNullChecks
is turned off.
const voidVar1: void = undefined;
const voidVar2: void = null; // Requires strictNullChecks to be off
Array Types#
Arrays are also one of the most commonly used types. In TypeScript, there are two ways to declare an array type:
const arr1: string[] = [];
const arr2: Array<string> = [];
These two methods are completely equivalent, but the former is more commonly used. If you hover over arr2
, you will find that its displayed type signature is string[]
. Arrays are a data structure we use extensively in daily development, but in some cases, using tuples is more appropriate, such as when an array only stores variables of fixed length and you want to give a type error when accessing out of bounds.
const arr4: [string, string, string] = ['lin', 'bu', 'du'];
console.log(arr4[599]);
At this point, a type error will occur: Tuple type of length "3" "[string, string, string]" has no element at index "599". In addition to elements of the same type, tuples can also declare multiple elements of different types that are strongly bound to their positions:
const arr5: [string, number, boolean] = ['linbudu', 599, true];
In this case, accessing indices within the valid boundaries of the array (i.e., 0, 1, 2) will accurately obtain the types at the corresponding positions. Tuples also support optional members at certain positions:
const arr6: [string, number?, boolean?] = ['linbudu'];
// The following is also valid
// const arr6: [string, number?, boolean?] = ['linbudu', , ,];
For members marked as optional, under the --strictNullChecks
configuration, they will be treated as a string | undefined
type. At this point, the length property of the tuple will also change, for example, the tuple arr6 has a length type of 1 | 2 | 3
:
type TupleLength = typeof arr6.length; // 1 | 2 | 3
You might feel that the readability of tuples is actually not very good. For example, for [string, number, boolean]
, you cannot directly know what these three elements represent, and it might be better to use an object form. In TypeScript 4.0, support for named tuples (Labeled Tuple Elements) allows us to label elements in a tuple similarly to properties:
const arr7: [name: string, age: number, male?: boolean] = ['linbudu', 599, true];
For tuples, implicit out-of-bounds access can also be flagged with a warning:
const arr5: [string, number, boolean] = ['linbudu', 599, true];
// Tuple type of length "3" "[string, number, boolean]" has no element at index "3".
const [name, age, male, other] = arr5;
Object Types#
- Each property value must correspond to the interface's property type.
- There cannot be extra properties, nor can there be fewer properties, including directly declaring within the object or assigning properties like
obj1.other = 'xxx'
.
Optional & Readonly#
interface IDescription {
name: string;
age: number;
male?: boolean;
func?: Function;
}
const obj2: IDescription = {
name: 'linbudu',
age: 599,
male: true,
// It's valid not to implement func
};
interface IDescription {
readonly name: string;
age: number;
}
const obj3: IDescription = {
name: 'linbudu',
age: 599,
};
// Cannot assign to "name" because it is a readonly property
obj3.name = "林不渡";
In fact, there are also readonly modifiers at the array and tuple level, but they differ from object types in two ways.
- You can only mark the entire array/tuple as readonly, not mark a specific property as readonly like in objects.
- Once marked as readonly, the type of this readonly array/tuple will no longer have methods like push, pop, etc. (i.e., methods that modify the original array), so the error message will be Property "push" does not exist on type xxx. The essence of this implementation is that the type of readonly arrays and readonly tuples actually becomes ReadonlyArray, rather than Array.
type vs interface#
Interface is used to describe the structure of objects and classes, while type aliases are used to abstract a function signature, a set of union types, a utility type, etc., into a complete independent type.
object, Object, and { }#
Object includes all types:
// For undefined, null, void 0, strictNullChecks needs to be turned off
const tmp1: Object = undefined;
const tmp2: Object = null;
const tmp3: Object = void 0;
const tmp4: Object = 'linbudu';
const tmp5: Object = 599;
const tmp6: Object = { name: 'linbudu' };
const tmp7: Object = () => {};
const tmp8: Object = [];
Similar to Object are Boolean, Number, String, Symbol, these boxed types also include some unexpected types. For example, String also includes undefined, null, void, and the unboxed type string, but does not include the unboxed types corresponding to other boxed types, such as boolean and primitive object types. Let's look at the following code:
const tmp9: String = undefined;
const tmp10: String = null;
const tmp11: String = void 0;
const tmp12: String = 'linbudu';
// The following are invalid because they are not unboxed types of string
const tmp13: String = 599; // X
const tmp14: String = { name: 'linbudu' }; // X
const tmp15: String = () => {}; // X
const tmp16: String = []; // X
In any case, you should avoid using these boxed types.
The introduction of object is to solve the incorrect use of Object type, representing all non-primitive types, i.e., array, object, and function types:
const tmp17: object = undefined;
const tmp18: object = null;
const tmp19: object = void 0;
const tmp20: object = 'linbudu'; // X Invalid, value is a primitive type
const tmp21: object = 599; // X Invalid, value is a primitive type
const tmp22: object = { name: 'linbudu' };
const tmp23: object = () => {};
const tmp24: object = [];
Finally, there is {}
, a strange empty object. If you have learned about literal types, you can think of {}
as an object literal type (similar to string literal types). Otherwise, you can think of using {}
as a type signature as a valid but internally undefined empty object, similar to Object (think of new Object()
), meaning any non-null/undefined value:
const tmp25: {} = undefined; // Only valid when strictNullChecks is off, same below
const tmp26: {} = null;
const tmp27: {} = void 0; // void 0 is equivalent to undefined
const tmp28: {} = 'linbudu';
const tmp29: {} = 599;
const tmp30: {} = { name: 'linbudu' };
const tmp31: {} = () => {};
const tmp32: {} = [];
Although it can be used as a variable type, you actually cannot perform any assignment operations on this variable:
const tmp30: {} = { name: 'linbudu' };
tmp30.age = 18; // X Property "age" does not exist on type "{}".
We should also avoid using {}. {} means any non-null/undefined value, and from this perspective, using it is as bad as using any.
When you are unsure of a variable's specific type but can confirm it is not a primitive type, you can use object. However, I recommend further distinguishing it, using
Record<string, unknown>
orRecord<string, any>
to represent objects,unknown[]
orany[]
to represent arrays, and(...args: any[]) => any
to represent functions.
Literal Types#
They represent a more precise type than primitive types and are also subtypes of primitive types.
Literal types mainly include string literal types, number literal types, boolean literal types, and object literal types, which can be used directly as type annotations:
const str: "linbudu" = "linbudu";
const num: 599 = 599;
const bool: true = true;
// Error! Cannot assign type “"linbudu599"” to type “"linbudu"”.
const str1: "linbudu" = "linbudu599";
const str2: string = "linbudu";
const str3: string = "linbudu599";
Union Types#
You can understand union types as representing a collection of available types. As long as the final assigned type belongs to one of the members of the union type, it can be considered to conform to this union type.
interface Tmp {
bool: true | false;
num: 1 | 2 | 3;
str: "lin" | "bu" | "du"
mixed: true | string | 599 | {} | (() => {}) | (1 | 2)
}
- For function types in union types, parentheses
()
need to be used to wrap them. - Function types do not have literal types, so here
(() => {})
is a valid function type. - You can further nest union types within union types, but these nested union types will ultimately be flattened to the first level.
One common scenario for union types is to implement manual mutually exclusive properties through a union of multiple object types, meaning that if this property has field 1, it does not have field 2:
interface Tmp {
user:
| {
vip: true;
expires: string;
}
| {
vip: false;
promotion: string;
};
}
declare var tmp: Tmp;
if (tmp.user.vip) {
console.log(tmp.user.expires);
}
In this example, the user property will satisfy both normal user and VIP user types, where the type of the vip property is declared based on the boolean literal type. In actual use, we can ensure that the subsequent type inference will narrow its type to the VIP user type (i.e., the first branch of the union type) by checking this property for true.
We can also reuse a set of literal union types through type aliases:
type Code = 10000 | 10001 | 50000;
type Status = "success" | "failure";
Enum Types#
enum PageUrl {
Home_Page_Url = "url1",
Setting_Page_Url = "url2",
Share_Page_Url = "url3",
}
const home = PageUrl.Home_Page_Url;
The benefits of doing this are very clear. First, you have better type hints. Secondly, these constants are truly constrained within a namespace (the object declaration above always falls short). If you do not declare values for the enum, it will default to using numeric enums, starting from 0 and incrementing by 1.
If you only specify an enum value for one member, then the previously unassigned members will still use the incrementing method starting from 0, while subsequent members will start incrementing from the enum value.
enum Items {
// 0
Foo,
Bar = 599,
// 600
Baz
}
In numeric enums, you can use delayed evaluation enum values, such as functions:
const returnNum = () => 100 + 499;
enum Items {
Foo = returnNum(),
Bar = 599,
Baz
}
But note that delayed evaluation enum values are conditional. If you use delayed evaluation, then members that do not use delayed evaluation must be placed after members declared with constant enum values (as in the above example), or placed first:
enum Items {
Baz,
Foo = returnNum(),
Bar = 599,
}
In TypeScript, you can also use both string enum values and numeric enum values simultaneously:
enum Mixed {
Num = 599,
Str = "linbudu"
}
The important difference between enums and objects is that objects are one-way mappings, meaning we can only map from keys to values. In contrast, enums are two-way mappings, meaning you can map from enum members to enum values and also from enum values to enum members:
enum Items {
Foo,
Bar,
Baz
}
const fooValue = Items.Foo; // 0
const fooKey = Items[0]; // "Foo"
However, note that only enum members with numeric values can perform such two-way enumeration; string enum members will still only perform one-way mapping.
Constant Enums#
const enum Items {
Foo,
Bar,
Baz
}
const fooValue = Items.Foo; // 0
The difference between it and regular enums mainly lies in accessibility and compilation output. For constant enums, you can only access enum values through enum members (and cannot access members through values).
Functions#
Function Type Signatures#
The type of a function describes the parameter types and the return value type of the function.
function foo(name: string): number {
return name.length;
}
const foo = function (name: string): number {
return name.length
}
const foo: (name: string) => number = function (name) {
return name.length
}
const foo = (name: string): number => {
return name.length
}
const foo: (name: string) => number = (name) => {
return name.length
}
You can either declare the parameter and return value types directly in the function, or use a type alias to abstract the function declaration:
type FuncFoo = (name: string) => number
const foo: FuncFoo = (name) => {
return name.length
}
If you just want to describe the structure of this function type, you can even use an interface to declare the function:
interface FuncFooStruct {
(name: string): number
}
This interface is referred to as a Callable Interface. It may seem strange, but we can think of it this way: the interface is used to describe a type structure, and the function type is essentially a type with a fixed structure.
Optional Parameters and Rest Parameters#
// Injecting default values for optional parameters in function logic
function foo1(name: string, age?: number): number {
const inputAge = age || 18; // Or use age ?? 18
return name.length + inputAge
}
// Directly declaring default values for optional parameters
function foo2(name: string, age: number = 18): number {
const inputAge = age;
return name.length + inputAge
}
It is important to note that optional parameters must come after required parameters. After all, in JavaScript, function parameters are passed by position (positional parameters), not by parameter name (named parameters).
The type annotation for rest parameters is also quite simple, as they are essentially an array; we should use array types or tuple types for annotation:
function foo(arg1: string, ...rest: any[]) { }
function foo(arg1: string, ...rest: [number, boolean]) { }
Overloading#
To achieve a return value type associated with the input parameters, we can use TypeScript's function overload signatures:
function func(foo: number, bar: true): string;
function func(foo: number, bar?: false): number;
function func(foo: number, bar?: boolean): string | number {
if (bar) {
return String(foo);
} else {
return foo * 599;
}
}
const res1 = func(599); // number
const res2 = func(599, true); // string
const res3 = func(599, false); // number
One thing to note is that when a function has multiple overload declarations, it searches down the overload declaration order when being called. Therefore, in the first overload declaration, to keep it consistent with the logic, where bar is true and returns a string type, we need to declare bar as a required literal type in the first overload declaration.
In reality, TypeScript's overloads are more like pseudo-overloads; it only has one concrete implementation, and the overloads are reflected in the method call signatures rather than the concrete implementation. In languages like C++, overloads are reflected in multiple functions with the same name but different input parameters, which is a broader definition of function overloading.
Asynchronous Functions#
For asynchronous functions (i.e., functions marked as async), their return value must be a Promise type, and the type contained within the Promise is written in the form of generics, i.e., Promise<T>
async function asyncFunc(): Promise<void> {}
Class#
Class and Class Member Type Signatures#
The main structure of a function consists of parameters, logic, and return values. The type annotation for logic is essentially an annotation for ordinary code, so we only introduced type annotations for parameters and return values. The same applies to Class; its main structure consists of constructor, properties, methods, and accessors.
class Foo {
prop: string;
constructor(inputProp: string) {
this.prop = inputProp;
}
print(addon: string): void {
console.log(`${this.prop} and ${addon}`)
}
get propA(): string {
return `${this.prop}+A`;
}
set propA(value: string) {
this.prop = `${value}+A`
}
}
The only thing to note is that setter methods are not allowed to have return value type annotations; you can think of it as the return value of a setter not being consumed; it is a function that focuses solely on the process. Class methods can also be overloaded in the same way as functions, and the syntax is basically the same, so we won't elaborate further.
Modifiers#
In TypeScript, we can add these modifiers to Class members: public
/ private
/ protected
/ readonly
. Except for readonly, the other three are access modifiers, while readonly is an operational modifier (just like the meaning of readonly in interfaces). When you do not explicitly use access modifiers, the accessibility of members will default to public.
- public: This member can be accessed in the class, instances of the class, and subclasses.
- private: This member can only be accessed within the class.
- protected: This member can only be accessed within the class and its subclasses. You can think of classes and instances of classes as two different concepts; once instantiated (factory parts), they are no longer related to the class (factory), meaning protected members cannot be accessed.
class Foo {
private prop: string;
constructor(inputProp: string) {
this.prop = inputProp;
}
protected print(addon: string): void {
console.log(`${this.prop} and ${addon}`)
}
public get propA(): string {
return `${this.prop}+A`;
}
public set propA(value: string) {
this.propA = `${value}+A`
}
}
We usually do not add modifiers to constructors but let them remain public by default.
For simplicity, we can apply access modifiers to parameters in the constructor:
class Foo {
constructor(public arg1: string, private arg2: boolean) { }
}
new Foo("linbudu", true)
Static Members#
You can use the static keyword to mark a member as a static member:
class Foo {
static staticHandler() { }
public instanceHandler() { }
}
Unlike instance members, static members cannot be accessed through this within the class; they need to be accessed in the form of Foo.staticHandler
. Static members are not inherited by instances; they always belong only to the class defined (and its subclasses).
Inheritance, Implementation, and Abstract Classes#
class Base { }
class Derived extends Base { }
Which members in the base class can be accessed by the derived class is entirely determined by their access modifiers. We have already introduced that the derived class can access base class members marked with public
or protected
modifiers. Besides accessibility, methods in the base class can also be overridden in the derived class, but we can still access methods in the base class through super:
class Base {
print() { }
}
class Derived extends Base {
print() {
super.print()
// ...
}
}
When overriding base class methods in the derived class, we cannot ensure that this method in the derived class can override the base class method; what if the method does not exist in the base class? Therefore, TypeScript 4.3 introduced the override
keyword to ensure that the method the derived class attempts to override must be defined in the base class:
class Base {
printWithLove() { }
}
class Derived extends Base {
override print() {
// ...
}
}
In addition to base and derived classes, there is another important concept: abstract classes. An abstract class describes the structure and methods of a class. In simple terms, an abstract class describes what members (properties, methods, etc.) a class should have, and an abstract method describes the structure of this method in the actual implementation. We know that methods and functions are very similar, including structure; therefore, abstract methods essentially describe the parameter types and return value types of this method.
abstract class AbsFoo {
abstract absProp: string;
abstract get absGetter(): string;
abstract absMethod(name: string): string
}
class Foo implements AbsFoo {
absProp: string = "linbudu"
get absGetter() {
return "linbudu"
}
absMethod(name: string) {
return name
}
}
At this point, we must fully implement each abstract member of this abstract class. It is important to note that in TypeScript, static abstract members cannot be declared.
Interfaces can declare not only function structures but also class structures:
interface FooStruct {
absProp: string;
get absGetter(): string;
absMethod(input: string): string
}
class Foo implements FooStruct {
absProp: string = "linbudu"
get absGetter() {
return "linbudu"
}
absMethod(name: string) {
return name
}
}
Additionally, we can use Newable Interface to describe the structure of a class (similar to describing function structures with Callable Interface):
class Foo { }
interface FooStruct {
new(): Foo
}
declare const NewableFoo: FooStruct;
const foo = new NewableFoo();
any Type#
A variable marked as any type can accept values of any type, it can accept any type of value again after declaration, and it can be assigned to any other type of variable:
// A variable marked as any type can have values of any type
let anyVar: any = "linbudu";
anyVar = false;
anyVar = "linbudu";
anyVar = {
site: "juejin"
};
anyVar = () => { }
// Variables marked with specific types can also accept any type of value
const val1: string = anyVar;
const val2: number = anyVar;
const val3: () => {} = anyVar;
const val4: {} = anyVar;
You can perform any operations on any type variables, including assignments, accesses, method calls, etc. At this point, you can consider that type inference and checking are completely disabled:
let anyVar: any = null;
anyVar.foo.bar.baz();
anyVar[0][1][2].prop1;
The main significance of any type is to represent a boundless "any type" that is compatible with all types and can be compatible with all types. The essence of any is the top type in the type system.
- If you are using any due to type incompatibility errors, consider using type assertions instead.
- If you are using any because the type is too complex and you do not want to declare everything, consider asserting this type to the simplest type you need. For example, if you need to call
foo.bar.baz()
, you can assert foo to a type that has a bar method. - If you want to express an unknown type, a more reasonable way is to use unknown.
unknown Type#
A variable of unknown type can be reassigned to any other type, but can only be assigned to any and unknown type variables:
let unknownVar: unknown = "linbudu";
unknownVar = false;
unknownVar = "linbudu";
unknownVar = {
site: "juejin"
};
unknownVar = () => { }
const val1: string = unknownVar; // Error
const val2: number = unknownVar; // Error
const val3: () => {} = unknownVar; // Error
const val4: {} = unknownVar; // Error
const val5: any = unknownVar;
const val6: unknown = unknownVar;
One major difference between unknown and any is reflected when assigning to other variables; any is like "I can transform into countless forms and am everywhere", all types treat it as one of their own. In contrast, unknown is like "Although I can transform into countless forms, I firmly believe that I will obtain a definite type at some point in the future", only any and unknown treat it as one of their own. In simple terms, any gives up all type checks, while unknown does not. This is also reflected when accessing properties on unknown type variables:
let unknownVar: unknown;
unknownVar.foo(); // Error: Object type is unknown
To access properties on unknown types, type assertions are required, i.e., "Although this is an unknown type, I assure you it is this type here!":
let unknownVar: unknown;
(unknownVar as { foo: () => {} }).foo();
When the type is unknown, it is more recommended to use unknown for annotation. This means you are taking on the extra mental burden to ensure the type structure is valid everywhere, and when refactoring to a specific type later, you can still obtain the original type information while ensuring type checking exists.
never Type#
The never type carries no type information, so it will be directly removed in union types.
declare let v1: never;
declare let v2: void;
v1 = v2; // X Type void cannot be assigned to type never
v2 = v1;
In the type system of programming languages, the never type is known as the Bottom Type, which is the lowest type in the entire type system hierarchy. Like null and undefined, it is a subtype of all types, but only variables of type never can be assigned to another variable of type never.
We typically do not explicitly declare a never type; it is mainly used by type checking. However, there are certain cases where using never makes sense, such as a function that only throws errors:
function justThrow(): never {
throw new Error()
}
In type flow analysis, once a function with a return value type of never is called, the subsequent code will be considered invalid (i.e., unreachable):
function foo (input:number){
if(input > 1){
justThrow();
// Equivalent to code after return statement, i.e., Dead Code
const name = "linbudu";
}
}
We can also explicitly use it for type checking, which is the reason why the never type mysteriously disappears in union types.
if (typeof strOrNumOrBool === "string") {
// Definitely a string!
strOrNumOrBool.charAt(1);
} else if (typeof strOrNumOrBool === "number") {
strOrNumOrBool.toFixed();
} else if (typeof strOrNumOrBool === "boolean") {
strOrNumOrBool === true;
} else {
const _exhaustiveCheck: never = strOrNumOrBool;
throw new Error(`Unknown input type: ${_exhaustiveCheck}`);
}
Suppose a careless colleague adds a new type branch, strOrNumOrBool
becomes strOrNumOrBoolOrFunc
, but forgets to add the corresponding handling branch. In this case, a type error will occur in the else block when trying to assign the Function type to the never type variable. This effectively utilizes the type analysis capability and the fact that only never types can be assigned to never types to ensure that union type variables are properly handled.
Type Assertions#
Type assertions can explicitly inform the type checker of the current type of a variable, allowing for type analysis corrections and type modifications. It is essentially an operation that changes the existing type of a variable to a newly specified type, with the basic syntax being as NewType
. You can assert any/unknown types to a specific type:
const str: string = "linbudu";
(str as any).func().foo().prop;
function foo(union: string | number) {
if ((union as string).includes("linbudu")) { }
if ((union as number).toFixed() === '599') { }
}
However, the correct way to use type assertions is when TypeScript's type analysis is incorrect or not as expected, asserting it to the correct type here:
interface IFoo {
name: string;
}
declare const obj: {
foo: IFoo
}
const {
foo = {} as IFoo
} = obj
Double Assertions#
When your assertion type and the original type differ too much, you need to assert to a general class first, i.e., any/unknown. This general type includes all possible types, so asserting to it and asserting from it to another type are not much different.
const str: string = "linbudu";
(str as unknown as { handler: () => {} }).handler();
// Using angle bracket assertion
(<{ handler: () => {} }>(<unknown>str)).handler();
Non-null Assertions#
Non-null assertions are actually a simplification of type assertions, using the !
syntax, i.e., obj!.func()!.prop
, marking the preceding declaration as definitely non-null (essentially removing null and undefined types), such as in this example:
declare const foo: {
func?: () => ({
prop?: number | null;
})
};
foo.func!().prop!.toFixed();
Common scenarios for non-null assertions also include document.querySelector
, Array.find
methods, etc.:
const element = document.querySelector("#id")!;
const target = [1, 2, 3, 599].find(item => item === 599)!;
Type assertions can also be used as a code hinting aid. For example, for the following slightly complex interface:
interface IStruct {
foo: string;
bar: {
barPropA: string;
barPropB: number;
barMethod: () => void;
baz: {
handler: () => Promise<void>;
};
};
}
Suppose you want to implement an object based on this structure, you might use type annotation:
const obj: IStruct = {};
At this point, you will encounter a bunch of type errors; you must implement the entire interface structure properly. However, if you use type assertions, you can implement this structure less completely while retaining type hints:
// This example will not throw an error
const obj = <IStruct>{
bar: {
baz: {},
},
};
References:
https://juejin.cn/book/7086408430491172901