「TypeScript 奇淫巧技」你真的了解类型断言吗?

在平时的 typescript 开发中,有时你比 ts 编译器更理解一个值对应的类型,通常是将编译器已推断类型向窄化的方向发展。这个时候你就会使用类型断言。下面是官网给出的语法格式:

let someValue: any = "this is a string";

let strLength: number = (<string>someValue).length;
复制代码

另一个为as语法:

let someValue: any = "this is a string";

let strLength: number = (someValue as string).length;
复制代码

类型断言的限制

既然类型断言如此强大可以推翻编译器对一个值类型的已有推论,这种危险技法必然有某种限制。

并不是所有的类型之间都可以进行类型断言,前提是两个类型之间要有 (父子/超子)类型关系 ,详细的来说可以分为一下两种情况:

  • 如果两个类型互相兼容,即 A 兼容 BB 也兼容 A。那么 A 能够被断言为 BB 也能被断言为 A
    • 这种情况的任意一个断言方向都可以被称之为安全的双向推断。
  • 除此之外,若 A 兼容 B,但是 B 不兼容 A,此情况下 A 也能够被断言为 BB 也能被断言为 A
    • 这种情况下,将 A 断言为 B,称之为不安全的类型窄化。
    • B 断言为 A,称之为安全的类型泛化(个人叫法)。

下面我们通过一个简化的例子,来理解类型断言的限制:

interface Animal {
    type: string;
}

interface Cat {
    type: string;
    miao(): void;
}

let tom: Cat = {
    type: 'land_animal'
    miao: () => console.log('miao');
};

let animal: Animal = cat;
复制代码

我们知道,TypeScript 是结构类型系统,类型之间的对比只会比较它们最终的结构,而会忽略它们定义时的关系。此时 cat 拥有 Animal 的全部属性,可以理解成它们的结构与 Cat extends Animal 是等价的。

此时我们就可以说 Animal 兼容 Cat 了,当 Animal 兼容 Cat 时,它们就可以互相进行类型断言了:

interface Animal {
    type: string;
}
interface Cat {
    type: string;
    miao(): void;
}

function cookAnimal(animal: Animal) {
    return (animal as Cat);
}
function cookCat(cat: Cat) {
    return (cat as Animal);
}
复制代码

这样的设计其实也很容易就能理解:

  • 允许 animal as Cat 是因为「父类可以被断言为子类」,相当于是不太安全的类型窄化。
  • 允许 cat as Animal 是因为既然子类拥有父类的属性和方法,那么被断言为父类,获取父类的属性、调用父类的方法,就不会有任何问题,故「子类可以被断言为父类」,相当于是安全的类型泛化。

需要注意的是,这里我们使用了简化的父类子类的关系来表达类型的兼容性,而实际上 TypeScript 在判断类型的兼容性时,比这种情况复杂很多,下面是官往上给出的类型兼容性关系表:

值\变量 any unknown object void undefined null never
any →
unknown →
object →
void →
undefined →
null →
never →

列表示变量(常量)的类型,行表示要分配给变量(常量)的值。 “”表示仅在--strictNullChecks关闭时才兼容的类型。

  • 任何类型的值都可以分配给其对应类型的变量。

  • anyunknown可以看做是所有类型的超类,作为变量的时候接受任何类型的值。不过作为值得时候稍有不同:

    • any 作为值可以分配给任何类型;
    • unknown 只能分配给 any
  • never 是所有类型的子类型,值为 never 类型可以赋给所有类型的变量。但是反之 never 变量只能接受 never

  • void 变量接受 anyundefinednever。但是作为值只能赋给 anyunknown

  • undefined 作为变量可以接受 anynever,作为值可以赋给 anyunknownvoid

  • nullundefined 的行为差不多,只是不能赋个 void

接下来我们用几个实际的例子来理解。

undefined 和 void

在类型兼容性中官方这样阐述二者关系:

undefined 作为变量可以接受 anynever,作为值可以赋给 anyunknownvoid

也就是说 undefinedvoid 的子类型,二者之间有超子关系,于是类型断言的条件就满足了:

const vod = () => {};
vod() as undefined; // 类型窄化

let undef: undefined;
undef as void; 	    // 类型泛化
复制代码

any、unknown 和 任何类型

类型兼容性中有这样的说明:

anyunknown可以看做是所有类型的超类,作为变量的时候接受任何类型的值。不过作为值得时候稍有不同:

  • any 作为值可以分配给任何类型;
  • unknown 只能分配给 any

总结一下,any 和所有类型互相兼容,unknown 是所有类型的超类,两个类型分别和所有类型满足断言关系,于是就有这样的写法:

// 任何类型 as unknown as 任何类型
// 任何类型 as any as 任何类型

let a = 1;
a as any as string
a as unknown as string
复制代码

这样做当然是不对的,利用了类型断言的漏洞,不过在某些特殊的情况下非常有用。

联合类型

联合类型是联合成员的父类型,所以任何联合类型都可以被推断为其成员,其成员也可以逆推为联合类型。

举一个很有趣的例子:

image-20220105182242851.png 这个例子里,我们将 Dog 推断为 Cat | Dog 又推断为 Cat,这是一种 hack 的做法,Dog 直接推断为 Cat 是不行的,联合类型的成员之间不可以互推。

函数类型

首先我们要知道函数类型要兼容,函数参数必须是逆变的,返回值必须是协变的,那么是不是满足函数兼容性两个就可以互推呢?

来看看下面的例子,还是上面例子里的 CatDog,首先 CDToCD 赋值给 CToCD 满足参逆返协,这 ok。而 CToCD 赋值给 CDToCD 则不满足(刚好反了),所以这样写是不兼容的:

image-20220105184344726.png 如何让第二行代码跑通不报错呢,很简单,两次断言:

type CDToCD = (c: Cat | Dog) => Cat | Dog;
type CToCD = (c: Cat) => Cat | Dog;

const fn1: CDToCD = ((cd: Cat) => ({ miao() {} })) as CToCD as CDToCD;
复制代码

双重断言

前面的例子里我们使用好几个双重断言了,实际开发中真的可以利用这个技巧来完成一些编译器认为不可以兼容,但是你认为可以兼容的类型推断,既然:

  • 任何类型都可以被断言为 any|unknown
  • any|unknown 可以被断言为任何类型

那么我们可以使用双重断言 as any as Foo 来将任何一个类型断言为任何另一个类型。 比如在 react-use 中由于被 useCallback 包裹的函数返回的类型不能被正确推断,于是就是用了这个技巧:

const useAsyncFn = <T extends FuncReturnPromise>(
    fn: T,
    deps: DependencyList = []
): AsyncFnReturn<T> => {
    const [asyncState, setAsyncState] = useState<
        StateFromFunctionReturningPromise<T>
    >({
        loading: false,
    });
    const isMount = useMountedState();
    const fetch = useCallback((...args: Parameters<T>): ReturnType<T> => {
        !asyncState.loading &&
            setAsyncState({
                loading: true,
            });
        return fn(...args)
            .then((res) => {
                isMount() && setAsyncState({ loading: false, value: res });
            })
            .catch((err) => {
                isMount() && setAsyncState({ loading: false, error: err });
            }) as ReturnType<T>;
    }, deps);
    return [asyncState, fetch as unknown as T];
};
复制代码

Guess you like

Origin juejin.im/post/7050064152194187295