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

huangapple go评论59阅读模式
英文:

Typescript different class property types depending on arguments

问题

以下是代码的翻译部分:

// `IUser`是一个具有用户属性如`id`、`email`等的接口。
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)
    }
  }
}

请注意,上述代码是 TypeScript 代码,用于创建一个 User 类,根据传入的用户对象的状态来定义不同的属性类型。如果有其他需要翻译的部分,请提供具体内容。

英文:

I have a User class, which I instantiate with an object, which may be null if the user is not authenticated. I would like to make different property types depending on its state. This is what I tried:

// `IUser` is an interface with user properties like `id`, `email`, etc.
class User&lt;T extends IUser | null&gt; 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)
    }
  }
}

But this solution causes an error Type &#39;true&#39; is not assignable to type &#39;T extends null ? false : true&#39; when setting the authenticated field, and a bunch of errors like property &#39;email&#39; doesn&#39;t exist on type &#39;IUser | null&#39; in the else clause. My issue is related to this one, where the response suggested to do this.authenticated = true as any. In fact, this does remove one error, but the whole point of this headache is to be able to write

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

which doesn't work with this solution.

答案1

得分: 2

It seems you would like the User type to be a discriminated union like {authenticated: true, age: number, email: string, ...} | {authenticated: false, age: null, email: null, ...}. Unfortunately, class and interface types cannot themselves be unions, so there's no simple way to represent this.

Your attempt with conditional types would only possibly work from the outside of the class implementation, and then only if your user is of type User<IUser> | User<null>, which becomes a discriminated union. But the type User<IUser | null> is not equivalent to that. And furthermore, it doesn't work inside the class implementation, where the type parameter T is unspecified because the compiler doesn't do control-flow type analysis on generic types; see microsoft/TypeScript#13995 and microsoft/TypeScript#24085. So we should find a different way to represent this.


By far the most straightforward way is to make User have a possible-IUser, and not be a possible-IUser. This principle of "having" instead of "being" is known as composition over inheritance. And instead of checking some other property named unauthenticated, you'd only have to check the property of a possible-IUser type. Here it is:

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

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

If you really want to check an authenticated property to distinguish between the presence and absence of an IUser, you can still do it by making the user property a discriminated union like this:

// 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: "alice@example.com", age: 30 });
if (u.user.authenticated) {
  console.log(u.user.id.toUpperCase());
}

But the extra complexity here might not be worth the benefit.


If you really need the User class to be a possible-IUser, then you have other options. The normal way of getting union types with classes would be to use inheritance; have an abstract BaseUser class that captures behavior common to both authenticated and unauthenticated users, like this:

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() {

  }
}

And then make two subclasses, AuthenticatedUser and UnauthenticatedUser, which specialize the behavior for the different types:

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();
  }
}

Now instead of having a single class constructor you have two, so your User type would be a union, and to get a new one you might use a function instead of a class constructor:

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

And it has the expected behavior:

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

Finally, if you absolutely must have a single class corresponding to your User union, you can do it by making a class implement a wider non-union type and then assert that the constructor for that class makes union instances. It's not very type-safe inside the class implementation, but it should work as well outside. Here's the implementation:

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)
    }
  }
}

Note that I named it _User so that the name User is available for the following type and constructor definitions:

// 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;

And now this works as you might have expected, using new User():

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

Okay, done. It's up to you which way to go here. Personally, I'd recommend the simplest solution, which requires some refactoring but is the most TypeScript-friendly way forward.

英文:

It seems you would like the User type to be a discriminated union like {authenticated: true, age: number, email: string, ...} | {authenticated: false, age: null, email: null, ...}. Unfortunately, class and interface types cannot themselves be unions, so there's no simple way to represent this.

Your attempt with conditional types would only possibly work from the outside of the class implementation, and then only if your user is of type User&lt;IUser&gt; | User&lt;null&gt;, which becomes a discriminated union. But the type User&lt;IUser | null&gt; is not equivalent to that. And furthermore it doesn't work inside the class implementation, where the type parameter T is unspecified, because the compiler doesn't do control-flow type analysis on generic types'; see microsoft/TypeScript#13995 and microsoft/TypeScript#24085. So we should find a different way to represent this.


By far the most straightforward way is to make User have a possible-IUser, and not be a possible-IUser. This principle of "having" instead of "being" is known as composition over inheritance. And instead of checking some other property named unauthenticated, you'd only have to check the property of possible-IUser type. Here it is:

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

const u = new User(Math.random() &lt; 0.5 ? null : 
  { id: &quot;alice&quot;, email: &quot;alice@example.com&quot;, age: 30 });
if (u.user) {
  console.log(u.user.id.toUpperCase());
}

If you really want to check an authenticated property to distinguish between the presence and absence of an IUser, you can still do it by making the user property a discriminated union like this:

// the discriminated type from above, written out programmatically
type PossibleUser = (IUser &amp; { authenticated: true }) |
  ({ [K in keyof IUser]: null } &amp; { 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() &lt; 0.5 ? null :
  { id: &quot;alice&quot;, email: &quot;alice@example.com&quot;, age: 30 });
if (u.user.authenticated) {
  console.log(u.user.id.toUpperCase());
}

but the extra complexity here might not be worth the benefit.


If you really need the User class to be a possible-IUser, then you have other options. The normal way of getting union types with classes would be to use inheritance; have an abstract BaseUser class which captures behavior common to both authenticated and unauthenticated users, like this:

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() {

  }
}

And then make two subclasses, AuthenticatedUser and UnauthenticatedUser which specialize the behavior for the different types:

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();
  }
}

Now instead of having a single class constructor you have two, so your User type would be a union, and to get a new one you might use a function instead of a class constructor:

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

And it has the expected behavior:

const u = newUser(Math.random() &lt; 0.5 ? null : 
  { id: &quot;alice&quot;, email: &quot;alice@example.com&quot;, age: 30 });
if (u.authenticated) {
  console.log(u.id.toUpperCase());
}

Finally, if you absolutely must have a single class corresponding to your User union, you can do it by making a class implement a wider non-union type, and then assert that the constructor for that class makes union instances. It's not very type safe inside the class implementation, but it should work as well outside. Here's the implementation:

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)
    }
  }
}

Note that I named it _User so that the name User is available for the following type and constructor definitions:

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

And now this works as you might have expected, using new User():

const u = new User(Math.random() &lt; 0.5 ? null : 
  { id: &quot;alice&quot;, email: &quot;alice@example.com&quot;, age: 30 });
if (u.authenticated) {
  console.log(u.id.toUpperCase());
}

Okay, done. It's up to you which way to go here. Personally I'd recommend the simplest solution, which requires some refactoring but is the most TypeScript-friendly way forward.

Playground Link to code

huangapple
  • 本文由 发表于 2020年1月6日 15:16:15
  • 转载请务必保留本文链接:https://go.coder-hub.com/59608071.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定