Desmond

Desmond

An introvert who loves web programming, graphic design and guitar
github
bilibili
twitter

TypeScript Type Programming

Type Aliases#

Extracting a group of union types:

type StatusCode = 200 | 301 | 400 | 500 | 502;
type PossibleDataTypes = string | number | (() => unknown);

const status: StatusCode = 502;

Extracting a function type:

type Handler = (e: Event) => void;

const clickHandler: Handler = (e) => { };
const moveHandler: Handler = (e) => { };
const dragHandler: Handler = (e) => { };

In type aliases, type aliases can declare that they can accept generics (which I call generic placeholders). Once generics are accepted, we call it a utility type:

type MaybeNull<T> = T | null;
function process(input: MaybeNull<{ handler: () => {} }>) {
  input?.handler();
}
type MaybeArray<T> = T | T[];

// We will learn about function generics later~
function ensureArray<T>(input: MaybeArray<T>): T[] {
  return Array.isArray(input) ? input : [input];
}

Union Types and Intersection Types#

In fact, just as the symbol for union types is |, it represents a bitwise OR, meaning that only one type from the union type needs to be satisfied to consider it as implementing this union type, such as A | B, which only requires implementing A or B.

The & representing bitwise AND is different; you need to satisfy all types here to say that you have implemented this intersection type, that is, A & B, both A and B types must be satisfied.

interface NameStruct {
  name: string;
}

interface AgeStruct {
  age: number;
}

type ProfileStruct = NameStruct & AgeStruct;

const profile: ProfileStruct = {
  name: "linbudu",
  age: 18
}
type StrAndNum = string & number; // never
type UnionIntersection1 = (1 | 2 | 3) & (1 | 2); // 1 | 2
type UnionIntersection2 = (string | number | symbol) & string; // string

Index Types#

Index Signature Types#

Index signature types mainly refer to quickly declaring a type structure with consistent key-value types in interfaces or type aliases using the following syntax:

interface AllStringTypes {
  [key: string]: string;
}

type PropType1 = AllStringTypes['linbudu']; // string
type PropType2 = AllStringTypes['599']; // string

However, due to JavaScript's behavior, accessing in the form of obj[prop] will convert numeric index access to string index access, meaning that obj[599] and obj['599'] have the same effect. Therefore, we can still declare numeric type keys in string index signature types. Similarly, the symbol type is also applicable:

const foo: AllStringTypes = {
  "linbudu": "599",
  599: "linbudu",
  [Symbol("ddd")]: 'symbol',
}

Index signature types can also coexist with specific key-value type declarations, but in this case, these specific key-value types must also conform to the index signature type declaration:

interface AllStringTypes {
  // Property "propA" of type "number" cannot be assigned to string index type "boolean".
  propA: number;
  [key: string]: boolean;
}

interface StringOrBooleanTypes {
  propA: number;
  propB: boolean;
  [key: string]: number | boolean;
}

A common scenario for index signature types is when refactoring JavaScript code, declaring an any index signature type for objects with many internal properties to temporarily support access to properties with undefined types, and gradually completing the types later:

interface AnyTypeHere {
  [key: string]: any;
}

const foo: AnyTypeHere['linbudu'] = 'any value';

Index Type Queries#

The keyof operator can convert all keys in an object to corresponding literal types and then combine them into a union type. Note that numeric key names will not be converted to string literal types but will remain as numeric literal types.

interface Foo {
  linbudu: 1,
  599: 2
}

type FooKeys = keyof Foo; // "linbudu" | 599
// In VS Code, hovering the mouse will only show 'keyof Foo'
// You cannot see the actual values, you can do this:
type FooKeys = keyof Foo & {}; // "linbudu" | 599

Index Type Access#

The essence of index type queries is to access the key's corresponding value type through the key's literal type.

interface NumberRecord {
  [key: string]: number;
}

type PropType = NumberRecord[string]; // number
interface Foo {
  propA: number;
  propB: boolean;
}

type PropAType = Foo['propA']; // number
type PropBType = Foo['propB']; // boolean
interface Foo {
  propA: number;
  propB: boolean;
  propC: string;
}

type PropTypeUnion = Foo[keyof Foo]; // string | number | boolean

Mapped Types#

The main purpose of mapped types is to map key names to value types.

type Stringify<T> = {
  [K in keyof T]: string;
};

interface Foo {
  prop1: string;
  prop2: number;
  prop3: boolean;
  prop4: () => void;
}

type StringifiedFoo = Stringify<Foo>;

// Equivalent to
interface StringifiedFoo {
  prop1: string;
  prop2: string;
  prop3: string;
  prop4: string;
}

This utility type accepts an object type (assuming we will only use it this way), uses keyof to obtain the literal union type of the keys of this object type, and then maps each member of this union type through mapped types (i.e., the in keyword here) and sets its value type to string.

type Clone<T> = {
  [K in keyof T]: T[K];
};

Here, T[K] is actually the index type access mentioned above; we accessed the value type using the key's literal type, which is equivalent to cloning an interface. It is important to note that only K in belongs to the syntax of mapped types, keyof T belongs to the keyof operator, and the [] in [K in keyof T] belongs to the index signature type, while T[K] belongs to index type access.

image

Type Query Operator#

const str = "linbudu";

const obj = { name: "linbudu" };

const nullVar = null;
const undefinedVar = undefined;

const func = (input: string) => {
  return input.length > 10;
}

type Str = typeof str; // "linbudu"
type Obj = typeof obj; // { name: string; }
type Null = typeof nullVar; // null
type Undefined = typeof undefined; // undefined
type Func = typeof func; // (input: string) => boolean
const func = (input: string) => {
  return input.length > 10;
}

const func2: typeof func = (name: string) => {
  return name === 'linbudu'
}
const func = (input: string) => {
  return input.length > 10;
}

// boolean
type FuncReturnType = ReturnType<typeof func>;

In most cases, the type returned by typeof is the inferred type that appears when you hover over the variable name, and it is the narrowest level of inference (i.e., down to the level of literal types). You also don't need to worry about mixing these two types of typeof; the typeof used in logical code will definitely be the typeof in JavaScript, while the typeof in type code (such as type annotations, type aliases, etc.) will definitely be the type query typeof. Additionally, to better avoid this situation, which is to isolate the type layer from the logic layer, expressions are not allowed after the type query operator:

const isInputValid = (input: string) => {
  return input.length > 10;
}

// Expressions are not allowed
let isValid: typeof isInputValid("linbudu");

Type Guards#

TypeScript provides very powerful type inference capabilities, which continuously attempt to narrow down types based on your code logic. This ability is called control flow analysis of types (which can also be simply understood as type inference). This is one of the most important parts of a programming language's type capabilities: types closely related to actual logic. We infer types from logic and then let types safeguard the logic.

function isString(input: unknown): boolean {
  return typeof input === "string";
}

function foo(input: string | number) {
  if (isString(input)) {
    // Property "replace" does not exist on type "string | number".
    (input).replace("linbudu", "linbudu599")
  }
  if (typeof input === 'number') { }
  // ...
}

Because the isString function is defined elsewhere, the internal judgment logic is not in the foo function. The type control flow analysis cannot collect type information across function contexts (though other type languages may support this).

In fact, it is very common to encapsulate judgment logic and extract it for reuse outside of functions. To address the limitation of type control flow analysis, TypeScript introduces the is keyword to explicitly provide type information:

function isString(input: unknown): input is string {
  return typeof input === "string";
}

function foo(input: string | number) {
  if (isString(input)) {
    // Correct
    (input).replace("linbudu", "linbudu599")
  }
  if (typeof input === 'number') { }
  // ...
}

is string, that is, is keyword + expected type, means that if this function successfully returns true, then the type of the parameter before the is keyword will be collected by the type guard's subsequent type control flow analysis.

It is important to note that the type guard function does not check the association between the judgment logic and the actual type:

function isString(input: unknown): input is number {
  return typeof input === "string";
}

function foo(input: string | number) {
  if (isString(input)) {
    // Error, it becomes number type here
    (input).replace("linbudu", "linbudu599")
  }
  if (typeof input === 'number') { }
  // ...
}

From this perspective, type guards are somewhat similar to type assertions, but type guards are more lenient and trust you more. Whatever type you specify, it will be that type.

export type Falsy = false | "" | 0 | null | undefined;

export const isFalsy = (val: unknown): val is Falsy => !val;

// Excluding less common symbol and bigint
export type Primitive = string | number | boolean | undefined;

export const isPrimitive = (val: unknown): val is Primitive => ['string', 'number', 'boolean' , 'undefined'].includes(typeof val);

Type Protection Based on in and instanceof#

interface Foo {
  foo: string;
  fooOnly: boolean;
  shared: number;
}

interface Bar {
  bar: string;
  barOnly: boolean;
  shared: number;
}

function handle(input: Foo | Bar) {
  if ('foo' in input) {
    input.fooOnly;
  } else {
    input.barOnly;
  }
}

function handle(input: Foo | Bar) {
  if ('shared' in input) {
    // Property "fooOnly" does not exist on type "Foo | Bar". Property "fooOnly" does not exist on type "Bar".
    input.fooOnly;
  } else {
    // Property "barOnly" does not exist on type "never".
    input.barOnly;
  }
}

At this point, someone might ask why shared cannot be used to distinguish. The answer is clear: because it exists in both types and does not have distinguishing power. However, foo / bar and fooOnly / barOnly are unique properties of each type, so they can serve as discriminant properties. Foo and Bar have distinguishing properties, making them discriminated union types. Although they are a union of types, each type has a unique property that sets it apart.

This discriminant property can be structural, such as the property prop being an array in structure A and an object in structure B, or structure A having property prop while structure B does not.

It can even be a difference in literal types of common properties:

function ensureArray(input: number | number[]): number[] {
  if (Array.isArray(input)) {
    return input;
  } else {
    return [input];
  }
}

interface Foo {
  kind: 'foo';
  diffType: string;
  fooOnly: boolean;
  shared: number;
}

interface Bar {
  kind: 'bar';
  diffType: number;
  barOnly: boolean;
  shared: number;
}

function handle1(input: Foo | Bar) {
  if (input.kind === 'foo') {
    input.fooOnly;
  } else {
    input.barOnly;
  }
}

function handle2(input: Foo | Bar) {
  // Error, does not serve as a distinction, in both code blocks it is Foo | Bar
  if (typeof input.diffType === 'string') {
    input.fooOnly;
  } else {
    input.barOnly;
  }
}
class FooBase {}

class BarBase {}

class Foo extends FooBase {
  fooOnly() {}
}
class Bar extends BarBase {
  barOnly() {}
}

function handle(input: Foo | Bar) {
  if (input instanceof FooBase) {
    input.fooOnly();
  } else {
    input.barOnly();
  }
}

Assertion Guards#

The biggest difference between assertion guards and type guards is that assertion guards need to throw an error when the judgment condition fails, while type guards only need to exclude the expected type.

function assert(condition: any, msg?: string): asserts condition {
  if (!condition) {
    throw new Error(msg);
  }
}

Here, asserts condition is used, and condition comes from the actual logic! This also means that we use the logical expression of condition as the basis for type judgment, equivalent to using a logical expression in the return type for type annotation.

let name: any = 'linbudu';

function assertIsNumber(val: any): asserts val is number {
  if (typeof val !== 'number') {
    throw new Error('Not a number!');
  }
}

assertIsNumber(name);

// It is of number type!
name.toFixed();

In this case, you do not need to pass an expression to the assertion guard; instead, you can place the expression used for judgment inside the assertion guard to achieve more independent code logic.


References:
https://juejin.cn/book/7086408430491172901

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.