从TypeScript中的基本类型创建唯一类型?

bzzcjhmw  于 2023-06-30  发布在  TypeScript
关注(0)|答案(6)|浏览(126)

我想给予类对象唯一的id类型,即使它们都是字符串。我尝试使用type,并尝试从具有唯一子类名称的基类派生。
请参见以下示例。typeextends都不允许我指示编译器将它们视为唯一类型。我仍然可以将HumanId传递给期望AnimalId的函数,反之亦然。
我知道它们是对象兼容的,从底层JavaScript的Angular 来看,这是完全有意义的。事实上,如果我向AnimalId添加一个unique成员,我会得到预期的错误:

Argument of type 'HumanId' is not assignable to parameter of type 'AnimalId'.

TypeScript是否有一个好的方法来为基本类型创建唯一的类型别名?

// type HumanId = string;
// type AnimalId = string;

class id {
    constructor(public value: string) { }
    toString(): string { return this.value;}
}
class HumanId extends id { };
class AnimalId extends id { };

function humanTest(id: HumanId): void {

}

function animalTest(id: AnimalId): void {

}

let h: HumanId = new HumanId("1");
let a: AnimalId = new AnimalId("2");

animalTest(h);
00jrzges

00jrzges1#

我遇到了这个问题,但我的用例有一个小转折:我想为number引入一个独特的类型。考虑一个API,其中您有例如hours: numberminutes: numberseconds: number等。但是你希望类型系统强制所有单位的正确用法。
@Evert提到的blog post在这方面是一个很好的资源。这个想法是创建一个交叉点类型,其中包含一些实际上从未使用过的虚拟对象。创建新的唯一类型可以通过泛型帮助器类型来抽象。示例说明:

// Generic definition somewhere in utils
type Distinct<T, DistinctName> = T & { __TYPE__: DistinctName };

// Possible usages
type Hours = Distinct<number, "Hours">;
type Minutes = Distinct<number, "Minutes">;
type Seconds = Distinct<number, "Seconds">;

function validateHours(x: number): Hours | undefined {
  if (x >= 0 && x <= 23) return x as Hours;
}
function validateMinutes(x: number): Minutes | undefined {
  if (x >= 0 && x <= 59) return x as Minutes;
}
function validateSeconds(x: number): Seconds | undefined {
  if (x >= 0 && x <= 59) return x as Seconds;
}

现在,函数f(h: Hours, m: Minutes, s: Seconds)不能被任意的number调用,但可以确保完全的类型安全。还要注意,该解决方案没有内存/运行时开销。
在实践中,这种方法对我来说效果很好,因为这些“不同”类型可以隐式地用于需要number的地方。通过例如as Hour只需要反过来。一个小缺点是像hours += 1这样的表达式需要替换为hours = (hours + 1) as Hours。正如在博客文章中所展示的那样,好处通常会超过稍微更明确的语法。
旁注:我将泛型类型命名为Distinct,因为这个名字对我来说更自然,这是Nim编程语言中调用该特性的方式。

twh00eeo

twh00eeo2#

正如您提到的,这些类型在结构上是兼容的。使它们唯一的唯一方法是向它们添加唯一的属性。
如果你只想让编译器区分这两者,你可以添加一个没有运行时差异的虚拟唯一成员:

class HumanId extends id {
  private _humanId: HumanId; // Could be anything as long as it is unique from the other class
}
class AnimalId extends id {
  private _animalId: AnimalId;
}
ruyhziif

ruyhziif3#

在“TypeScript Deep Dive”GitBook中,有一个更简洁的enum用法来区分类型:

  • TypeScript中的枚举提供了一定级别的名义类型。如果两个枚举类型的名称不同,则它们不相等。我们可以利用这一事实为结构上兼容的类型提供名义类型。

将这种方法应用到我的原始示例中,我得到了下面的TypeScript,它确实不允许我在“人类”上调用animalTest。:)

enum HumanBrand { _ = '' };
enum AnimalBrand { _ = '' };

declare type HumanId = string & HumanBrand;
declare type AnimalId = string & AnimalBrand;

const makeId = <T extends string>(id:string):T => { return id as T; };

function humanTest(id: HumanId): void {
}

function animalTest(id: AnimalId): void {
}

let h: HumanId = '1' as HumanId;
let a: AnimalId = makeId<AnimalId>("2");

animalTest(h); // Argument of type 'HumanBrand' is not assignable to parameter of type 'AnimalBrand'.

_ = ''条目(或其他占位符)是必需的。

frebpwbc

frebpwbc4#

如果您希望这些数据类型在JSON中可序列化,以便在API或数据库结构中使用,则它们需要保持字符串基类型。有两种方法可以做到这一点。

(1)将ID类型实现为字符串文字

由于字符串字面量是字符串,TypeScript通常会做正确的事情。对于你的人类和动物示例,你可以创建下面的字符串文字类型和函数来安全地创建/强制这些类型。

const setOfExclusiveIdsAlreadyCreated = new Set<string>();
const setOfExclusivePrefixesAlreadyCreated = new Set<string>();
const createMutuallyExclusiveId = <ID_TYPE extends string>(idType: ID_TYPE, idPrefix: string = "") => {
  // Ensure we never create two supposedly mutually-exclusive IDs with the same type
  // (which would, then, not actually be exclusive).
  if (setOfExclusiveIdsAlreadyCreated.has(idType)) {
    throw Error("Each type of ID should have a unique ID type");
  }
  // If setting a prefix, make sure that same prefix hasn't been used by
  // another type.
  setOfExclusiveIdsAlreadyCreated.add(idType);
  if (idPrefix && idPrefix.length > 0) {
    if (setOfExclusivePrefixesAlreadyCreated.has(idPrefix)) {
      throw Error("If specifying a prefix for an ID, each Id should have a unique prefix.");
    }
    setOfExclusiveIdsAlreadyCreated.add(idPrefix);
  }
  return (idToCoerce?: string) =>
    (typeof(idToCoerce) === "string") ?
      // If a string was provided, coerce it to this type
      idToCoerce as ID_TYPE :
      // If no string was provided, create a new one.  A real implementation
      // should use a longer, better random string
      (idPrefix + ":" + Math.random().toString()) as ID_TYPE;
}

//
// Create our human type and animal types
//

// The human type will appear to typescript to always be the literal "[[[Human]]]"
const HumanId = createMutuallyExclusiveId("[[[HumanId]]]", "human");
type HumanId = ReturnType<typeof HumanId>;
// The animal type will appear to typescript to always be the literal "[[[Animal]]]"
const AnimalId = createMutuallyExclusiveId("[[[AnimalId]]]", "animal");
type AnimalId = ReturnType<typeof AnimalId>;

const firstHumanId: HumanId = HumanId("Adam");
const randomlyGeneratedHumanId = HumanId();
const firstAnimalId = AnimalId("Snake");

// You can copy human types from one to the other
const copyOfAHumanId: HumanId = firstHumanId;
const anotherCopyOfAHumanId: HumanId = randomlyGeneratedHumanId;

// You CANNOT assign a human type to an animal type.
const animalId: AnimalId = firstHumanId; // type error

// You can pass an animal to a function that takes animals.
( (animal: AnimalId) => { console.log("The animal is " + animal) } )(firstAnimalId);

// You CANNOT pass a human to a function that takes animals.
( (animal: AnimalId) => { console.log("The animal is " + animal) } )(firstHumanId); // type error

interface Animal { animalId: AnimalId, makeSound: () => void };

const poodleId = AnimalId("poodle");
const animals: {[key in AnimalId]: Animal} = {
  [poodleId]: {
    animalId: poodleId,
    makeSound: () => { console.log("yip!"); }
  }
};

(2)将ID类型实现为枚举

// The human type will appear to typescript to be an enum
enum HumanIdType { idMayOnlyRepresentHuman = "---HumanId---" };
// Since enums are both types and consts that allow you to
// enumerate the options, we only want the type part, we
// export only the type.
export type HumanId = HumanIdType;

// Do the same for animals
enum AnimalIdType { idMayOnlyRepresentAnimal = "---AnimalId---" };
export type AnimalId = AnimalIdType;

const firstHumanId = "Adam" as HumanId;
const firstAnimalId = "Snake" as AnimalId;

// You can copy human types from one to the other
const copyOfAHumanId: HumanId = firstHumanId;

// You CANNOT assign a human type to an animal type.
const animalId: AnimalId = firstHumanId; // type error

// You can pass an animal to a function that takes animals.
( (animal: AnimalId) => { console.log("The animal is " + animal) } )(firstAnimalId);

// You CANNOT pass a human to a function that takes animals.
( (animal: AnimalId) => { console.log("The animal is " + animal) } )(firstHumanId); // type error

interface Animal { animalId: AnimalId, makeSound: () => void };

const poodleId = "poodle" as AnimalId;
const animals: {[key in AnimalId]: Animal} = {
  [poodleId]: {
    animalId: poodleId,
    makeSound: () => { console.log("yip!"); }
  }
};

// In order for JSON encoding/decoding to just work,
// it's important that TypeScript considers enums
// of this form (those with only one field that i
// represented as a string) as basic strings.
const animalIdsAreAlsoStrings: string = poodleId;

两种实现方式的注意事项

你需要避免像这样输入用作Map的对象:

const animalIdToAnimalMap: {[key: AnimalId]: Animal} = {};

而是这样做

const animalIdToAnimalMap: {[key in AnimalId]?: Animal} = {}

尽管如此,一些打字工作不会完美。例如,如果使用Object.entries(animalIdToAnimalMap),则键将是字符串而不是AnimalId(AnimalId将是keyof typeof animalIdToAnimalMap的类型)。
希望TypeScript在未来提供互斥的Id,并改进Object. entries等函数的默认类型。在那之前,我希望这仍然有帮助。这些方法确实帮助我避免了许多错误,否则我会以错误的顺序传递id或混淆不同类型的id。

gwo2fgha

gwo2fgha5#

这里有另一个想法来探索。除了继承,你可以通过泛型定义一个唯一的ID类型:

class Id<Entity> {
  constructor(value) {
    this.value = value;
  }

  equals(id: Id<Entity>) {
    return this.value === id.value;
  }

  public readonly value;
  // This member exists for the sole purpose of making
  // one Id class structurally different to another
  private readonly _: Entity;  
}

class Animal {
  id: Id<Animal>;
  growl() {}
}

class Human {
  id: Id<Human>;
  makeCoffee() {}
}

const animal = new Animal();
animal.id = new Id<Animal>("3");

const human = new Human();
// This triggers a compilation error.
human.id = animal.id;

human.id = new Id<Human>("3");
// Another compilation error
const humanIsAnimal = human.id.equals(animal.id);

// Demonstrating compatibility
const animal2 = new Animal();
animal2.id = animal.id;
const animalsMatch = animal2.id.equals(animal.id);
ogq8wdun

ogq8wdun6#

有一种方法可以获得以下可用性:

type HumanId = Special<string, "HumanId">
type AnimalId = Special<string, "AnimalId">

let humanId: HumanId = "foobar" // success
let animalId: AnimalId = "foobar" // success

animalId = humanId // type error
humanId = animalId // type error

不需要铸造(as)!解决方案如下:

interface Specializing<TypeName> {
  _type?: TypeName
}

export type Special<T, TypeName> = T & Specializing<TypeName>

这个优秀的建议归功于一个Drew Colthorp。请注意,你可以通过泛化然后重新特化来强制通过类型约束:

let foo: HumanId = "something"
let bar: AnimalId = "something"
let intermediary: string = bar
foo = intermediary  // succeeds

...但是只要你使用HumanIdAnimalId而不是string,你就应该很好。
相反,“品牌化”(即在对象的类型中包含唯一的伪值)需要强制转换。bluenote 10提出的Distinct类型也是如此。顺便说一句,当搜索这个主题时,术语“不透明类型”和“名义类型”很有用。

相关问题