网站搜索

根据参数的不同,Typescript 的类属性类型


我有一个 User 类,我用一个对象实例化它,如果用户未认证,该对象可能是 null。我想根据其状态设置不同的属性类型。这是我尝试的方式:

// `IUser` is an interface with user properties like `id`, `email`, etc.
class User<T extends IUser | null> implements IUser {
  id: T extends null ? null : string
  email: T extends null ? null : string
  age: T extends null ? null : number
  // ...

  authenticated: T extends null ? false : true

  constructor(user: T) {
    if(user === null) {
      this.authenticated = false
      this.id = this.email = this.age = null
    } else {
      this.authenticated = true
      ;({
        id: this.id,
        email: this.email,
        age: this.age,
      } = user)
    }
  }
}

但是,当设置 authenticated 字段时,这个解决方案会导致错误 Type 'true' is not assignable to type 'T extends null ? false : true',并且在 else 子句中会出现一堆类似 property 'email' doesn't exist on type 'IUser | null' 的错误。我的问题与这个问题相关,在该问题的回答中建议使用 this.authenticated=true as any。实际上,这确实消除了一个错误,但整个头疼的关键在于能够写

if(user.authenticated) {
  // email is certainly not null
  const { email } = user 
}

这与这个解决方案不兼容。

答案#1

看起来你希望 User 类型像 {authenticated: true, age: number, email: string, ...} | {authenticated: false, age: null, email: null, ...} 这样的判别联合类型。不幸的是,class 和 interface 类型本身不能是联合类型,因此没有简单的方法来表示这一点。

使用条件类型的尝试可能只在类的实现外部有效,而且只有当你的 user 类型为 User<IUser> | User<null> 时才行,这会成为一个可区分的联合类型。但类型 User<IUser | null> 并不等同于此。此外,它在类的实现 内部 不起作用,因为类型参数 T 未指定,编译器不会对泛型类型进行控制流类型分析;参见 microsoft/TypeScript#13995 和 microsoft/TypeScript#24085。因此,我们应该找到另一种方式来表示这种情况。


到目前为止,最直接的方法是让 User 拥有 一个可能的 IUser,而不是 成为 一个可能的 IUser。这种“拥有”而不是“成为”的原则被称为组合优于继承。并且,你无需检查名为 unauthenticated 的其他属性,只需检查可能的 IUser 类型的属性。如下所示:

class User {
  constructor(public user: IUser | null) {}
}

const u = new User(Math.random() < 0.5 ? null : 
  { id: "alice", email: "[email ", age: 30 });
if (u.user) {
  console.log(u.user.id.toUpperCase());
}

如果你真的想检查一个 authenticated 属性以区分 IUser 的存在与否,你仍然可以通过将 user 属性设为如下的判别联合来实现:

// the discriminated type from above, written out programmatically
type PossibleUser = (IUser & { authenticated: true }) |
  ({ [K in keyof IUser]: null } & { authenticated: false });

// a default unauthenticated user
const nobody: { [K in keyof IUser]: null } = { age: null, email: null, id: null };

class User {
  user: PossibleUser;
  constructor(user: IUser | null) {
    this.user = user ? { authenticated: true, ...user } : { authenticated: false, ...nobody };
  }
}

const u = new User(Math.random() < 0.5 ? null :
  { id: "alice", email: "[email ", age: 30 });
if (u.user.authenticated) {
  console.log(u.user.id.toUpperCase());
}

但这里的额外复杂性可能不值得这个好处。


如果你真的需要 User 类 成为 一个可能的 IUser,那么你还有其他选项。获取类的联合类型的正常方法是使用继承;创建一个抽象的 BaseUser 类,它捕获认证用户和未认证用户共有的行为,如下所示:

type WideUser = { [K in keyof IUser]: IUser[K] | null };
abstract class BaseUser implements WideUser {
  id: string | null = null;
  email: string | null = null;
  age: number | null = null;
  abstract readonly authenticated: boolean;
  commonMethod() {

  }
}

然后创建两个子类,AuthenticatedUser 和 UnauthenticatedUser,用于针对不同类型的用户专门化行为:

class AuthenticatedUser extends BaseUser implements IUser {
  id: string;
  email: string;
  age: number;
  readonly authenticated = true;
  constructor(user: IUser) {
    super();
    ({
      id: this.id,
      email: this.email,
      age: this.age,
    } = user)
  }
}

type IUnauthenticatedUser = { [K in keyof IUser]: null };
class UnauthenticatedUser extends BaseUser implements IUnauthenticatedUser {
  id = null;
  email = null;
  age = null;
  readonly authenticated = false;
  constructor() {
    super();
  }
}

现在,不是只有一个类构造函数,而是有两个,所以你的 User 类型将是一个联合类型,而要创建一个新的实例,你可能会使用函数而不是类构造函数:

type User = AuthenticatedUser | UnauthenticatedUser;
function newUser(possibleUser: IUser | null): User {
  return (possibleUser ? new AuthenticatedUser(possibleUser) : new UnauthenticatedUser());
}

并且它具有预期的行为:

const u = newUser(Math.random() < 0.5 ? null : 
  { id: "alice", email: "[email ", age: 30 });
if (u.authenticated) {
  console.log(u.id.toUpperCase());
}

最后,如果你确实必须有一个对应于你的 User 联合类型的单一类,你可以通过让一个类实现一个更宽泛的非联合类型,然后断言该类的构造函数会创建联合类型的实例来实现。这在类的实现内部类型安全性不是很高,但在外部同样应该可行。实现如下:

class _User implements WideUser {
  id: string | null
  email: string | null
  age: number | null
  authenticated: boolean;
  constructor(user: IUser | null) {
    if (user === null) {
      this.authenticated = false
      this.id = this.email = this.age = null
    } else {
      this.authenticated = true;
      ({
        id: this.id,
        email: this.email,
        age: this.age,
      } = user)
    }
  }
}

请注意,我将其命名为_User,这样名称User就可以用于以下类型和构造函数的定义:

// discriminated union type, named PossibleUser before
type User = (IUser & { authenticated: true }) | 
  ({ [K in keyof IUser]: null } & { authenticated: false });
const User = _User as new (...args: ConstructorParameters<typeof _User>) => User;

现在这可以按你预期的那样工作,使用 new User():

const u = new User(Math.random() < 0.5 ? null : 
  { id: "alice", email: "[email ", age: 30 });
if (u.authenticated) {
  console.log(u.id.toUpperCase());
}

好的,完成了。这里由你决定选择哪种方式。就我个人而言,我会推荐最简单的解决方案,虽然需要一些重构,但这是最符合 TypeScript 的前进方式。

代码的 Playground 链接