UNPKG

10.4 kBTypeScriptView Raw
1declare const tag: unique symbol;
2
3export type TagContainer<Token> = {
4 readonly [tag]: Token;
5};
6
7type Tag<Token extends PropertyKey, TagMetadata> = TagContainer<{[K in Token]: TagMetadata}>;
8
9/**
10Attach a "tag" to an arbitrary type. This allows you to create distinct types, that aren't assignable to one another, for runtime values that would otherwise have the same type. (See examples.)
11
12The generic type parameters can be anything.
13
14Note that `Opaque` is somewhat of a misnomer here, in that, unlike [some alternative implementations](https://github.com/microsoft/TypeScript/issues/4895#issuecomment-425132582), the original, untagged type is not actually hidden. (E.g., functions that accept the untagged type can still be called with the "opaque" version -- but not vice-versa.)
15
16Also note that this implementation is limited to a single tag. If you want to allow multiple tags, use `Tagged` instead.
17
18[Read more about tagged types.](https://medium.com/@KevinBGreene/surviving-the-typescript-ecosystem-branding-and-type-tagging-6cf6e516523d)
19
20There have been several discussions about adding similar features to TypeScript. Unfortunately, nothing has (yet) moved forward:
21 - [Microsoft/TypeScript#202](https://github.com/microsoft/TypeScript/issues/202)
22 - [Microsoft/TypeScript#15408](https://github.com/Microsoft/TypeScript/issues/15408)
23 - [Microsoft/TypeScript#15807](https://github.com/Microsoft/TypeScript/issues/15807)
24
25@example
26```
27import type {Opaque} from 'type-fest';
28
29type AccountNumber = Opaque<number, 'AccountNumber'>;
30type AccountBalance = Opaque<number, 'AccountBalance'>;
31
32// The `Token` parameter allows the compiler to differentiate between types, whereas "unknown" will not. For example, consider the following structures:
33type ThingOne = Opaque<string>;
34type ThingTwo = Opaque<string>;
35
36// To the compiler, these types are allowed to be cast to each other as they have the same underlying type. They are both `string & { __opaque__: unknown }`.
37// To avoid this behaviour, you would instead pass the "Token" parameter, like so.
38type NewThingOne = Opaque<string, 'ThingOne'>;
39type NewThingTwo = Opaque<string, 'ThingTwo'>;
40
41// Now they're completely separate types, so the following will fail to compile.
42function createNewThingOne (): NewThingOne {
43 // As you can see, casting from a string is still allowed. However, you may not cast NewThingOne to NewThingTwo, and vice versa.
44 return 'new thing one' as NewThingOne;
45}
46
47// This will fail to compile, as they are fundamentally different types.
48const thingTwo = createNewThingOne() as NewThingTwo;
49
50// Here's another example of opaque typing.
51function createAccountNumber(): AccountNumber {
52 return 2 as AccountNumber;
53}
54
55function getMoneyForAccount(accountNumber: AccountNumber): AccountBalance {
56 return 4 as AccountBalance;
57}
58
59// This will compile successfully.
60getMoneyForAccount(createAccountNumber());
61
62// But this won't, because it has to be explicitly passed as an `AccountNumber` type.
63getMoneyForAccount(2);
64
65// You can use opaque values like they aren't opaque too.
66const accountNumber = createAccountNumber();
67
68// This will compile successfully.
69const newAccountNumber = accountNumber + 2;
70
71// As a side note, you can (and should) use recursive types for your opaque types to make them stronger and hopefully easier to type.
72type Person = {
73 id: Opaque<number, Person>;
74 name: string;
75};
76```
77
78@category Type
79@deprecated Use {@link Tagged} instead
80*/
81export type Opaque<Type, Token = unknown> = Type & TagContainer<Token>;
82
83/**
84Revert an opaque or tagged type back to its original type by removing the readonly `[tag]`.
85
86Why is this necessary?
87
881. Use an `Opaque` type as object keys
892. Prevent TS4058 error: "Return type of exported function has or is using name X from external module Y but cannot be named"
90
91@example
92```
93import type {Opaque, UnwrapOpaque} from 'type-fest';
94
95type AccountType = Opaque<'SAVINGS' | 'CHECKING', 'AccountType'>;
96
97const moneyByAccountType: Record<UnwrapOpaque<AccountType>, number> = {
98 SAVINGS: 99,
99 CHECKING: 0.1
100};
101
102// Without UnwrapOpaque, the following expression would throw a type error.
103const money = moneyByAccountType.SAVINGS; // TS error: Property 'SAVINGS' does not exist
104
105// Attempting to pass an non-Opaque type to UnwrapOpaque will raise a type error.
106type WontWork = UnwrapOpaque<string>;
107
108// Using a Tagged type will work too.
109type WillWork = UnwrapOpaque<Tagged<number, 'AccountNumber'>>; // number
110```
111
112@category Type
113@deprecated Use {@link UnwrapTagged} instead
114*/
115export type UnwrapOpaque<OpaqueType extends TagContainer<unknown>> =
116 OpaqueType extends Tag<PropertyKey, any>
117 ? RemoveAllTags<OpaqueType>
118 : OpaqueType extends Opaque<infer Type, OpaqueType[typeof tag]>
119 ? Type
120 : OpaqueType;
121
122/**
123Attach a "tag" to an arbitrary type. This allows you to create distinct types, that aren't assignable to one another, for distinct concepts in your program that should not be interchangeable, even if their runtime values have the same type. (See examples.)
124
125A type returned by `Tagged` can be passed to `Tagged` again, to create a type with multiple tags.
126
127[Read more about tagged types.](https://medium.com/@KevinBGreene/surviving-the-typescript-ecosystem-branding-and-type-tagging-6cf6e516523d)
128
129A tag's name is usually a string (and must be a string, number, or symbol), but each application of a tag can also contain an arbitrary type as its "metadata". See {@link GetTagMetadata} for examples and explanation.
130
131A type `A` returned by `Tagged` is assignable to another type `B` returned by `Tagged` if and only if:
132 - the underlying (untagged) type of `A` is assignable to the underlying type of `B`;
133 - `A` contains at least all the tags `B` has;
134 - and the metadata type for each of `A`'s tags is assignable to the metadata type of `B`'s corresponding tag.
135
136There have been several discussions about adding similar features to TypeScript. Unfortunately, nothing has (yet) moved forward:
137 - [Microsoft/TypeScript#202](https://github.com/microsoft/TypeScript/issues/202)
138 - [Microsoft/TypeScript#4895](https://github.com/microsoft/TypeScript/issues/4895)
139 - [Microsoft/TypeScript#33290](https://github.com/microsoft/TypeScript/pull/33290)
140
141@example
142```
143import type {Tagged} from 'type-fest';
144
145type AccountNumber = Tagged<number, 'AccountNumber'>;
146type AccountBalance = Tagged<number, 'AccountBalance'>;
147
148function createAccountNumber(): AccountNumber {
149 // As you can see, casting from a `number` (the underlying type being tagged) is allowed.
150 return 2 as AccountNumber;
151}
152
153function getMoneyForAccount(accountNumber: AccountNumber): AccountBalance {
154 return 4 as AccountBalance;
155}
156
157// This will compile successfully.
158getMoneyForAccount(createAccountNumber());
159
160// But this won't, because it has to be explicitly passed as an `AccountNumber` type!
161// Critically, you could not accidentally use an `AccountBalance` as an `AccountNumber`.
162getMoneyForAccount(2);
163
164// You can also use tagged values like their underlying, untagged type.
165// I.e., this will compile successfully because an `AccountNumber` can be used as a regular `number`.
166// In this sense, the underlying base type is not hidden, which differentiates tagged types from opaque types in other languages.
167const accountNumber = createAccountNumber() + 2;
168```
169
170@example
171```
172import type {Tagged} from 'type-fest';
173
174// You can apply multiple tags to a type by using `Tagged` repeatedly.
175type Url = Tagged<string, 'URL'>;
176type SpecialCacheKey = Tagged<Url, 'SpecialCacheKey'>;
177
178// You can also pass a union of tag names, so this is equivalent to the above, although it doesn't give you the ability to assign distinct metadata to each tag.
179type SpecialCacheKey2 = Tagged<string, 'URL' | 'SpecialCacheKey'>;
180```
181
182@category Type
183*/
184export type Tagged<Type, TagName extends PropertyKey, TagMetadata = never> = Type & Tag<TagName, TagMetadata>;
185
186/**
187Given a type and a tag name, returns the metadata associated with that tag on that type.
188
189In the example below, one could use `Tagged<string, 'JSON'>` to represent "a string that is valid JSON". That type might be useful -- for instance, it communicates that the value can be safely passed to `JSON.parse` without it throwing an exception. However, it doesn't indicate what type of value will be produced on parse (which is sometimes known). `JsonOf<T>` solves this; it represents "a string that is valid JSON and that, if parsed, would produce a value of type T". The type T is held in the metadata associated with the `'JSON'` tag.
190
191This article explains more about [how tag metadata works and when it can be useful](https://medium.com/@ethanresnick/advanced-typescript-tagged-types-improved-with-type-level-metadata-5072fc125fcf).
192
193@example
194```
195import type {Tagged} from 'type-fest';
196
197type JsonOf<T> = Tagged<string, 'JSON', T>;
198
199function stringify<T>(it: T) {
200 return JSON.stringify(it) as JsonOf<T>;
201}
202
203function parse<T extends JsonOf<unknown>>(it: T) {
204 return JSON.parse(it) as GetTagMetadata<T, 'JSON'>;
205}
206
207const x = stringify({ hello: 'world' });
208const parsed = parse(x); // The type of `parsed` is { hello: string }
209```
210
211@category Type
212*/
213export type GetTagMetadata<Type extends Tag<TagName, unknown>, TagName extends PropertyKey> = Type[typeof tag][TagName];
214
215/**
216Revert a tagged type back to its original type by removing all tags.
217
218Why is this necessary?
219
2201. Use a `Tagged` type as object keys
2212. Prevent TS4058 error: "Return type of exported function has or is using name X from external module Y but cannot be named"
222
223@example
224```
225import type {Tagged, UnwrapTagged} from 'type-fest';
226
227type AccountType = Tagged<'SAVINGS' | 'CHECKING', 'AccountType'>;
228
229const moneyByAccountType: Record<UnwrapTagged<AccountType>, number> = {
230 SAVINGS: 99,
231 CHECKING: 0.1
232};
233
234// Without UnwrapTagged, the following expression would throw a type error.
235const money = moneyByAccountType.SAVINGS; // TS error: Property 'SAVINGS' does not exist
236
237// Attempting to pass an non-Tagged type to UnwrapTagged will raise a type error.
238type WontWork = UnwrapTagged<string>;
239```
240
241@category Type
242*/
243export type UnwrapTagged<TaggedType extends Tag<PropertyKey, any>> =
244RemoveAllTags<TaggedType>;
245
246type RemoveAllTags<T> = T extends Tag<PropertyKey, any>
247 ? {
248 [ThisTag in keyof T[typeof tag]]: T extends Tagged<infer Type, ThisTag, T[typeof tag][ThisTag]>
249 ? RemoveAllTags<Type>
250 : never
251 }[keyof T[typeof tag]]
252 : T;