Skip to content

Commit

Permalink
feat: add metadata support to Tagged type
Browse files Browse the repository at this point in the history
  • Loading branch information
ethanresnick committed Mar 2, 2024
1 parent 3ef12b0 commit a134a83
Show file tree
Hide file tree
Showing 3 changed files with 88 additions and 27 deletions.
2 changes: 1 addition & 1 deletion index.d.ts
Expand Up @@ -31,7 +31,7 @@ export type {UndefinedOnPartialDeep} from './source/undefined-on-partial-deep';
export type {ReadonlyDeep} from './source/readonly-deep';
export type {LiteralUnion} from './source/literal-union';
export type {Promisable} from './source/promisable';
export type {Opaque, UnwrapOpaque, Tagged, UnwrapTagged} from './source/opaque';
export type {Opaque, UnwrapOpaque, Tagged, GetTagMetadata, UnwrapTagged} from './source/opaque';
export type {InvariantOf} from './source/invariant-of';
export type {SetOptional} from './source/set-optional';
export type {SetReadonly} from './source/set-readonly';
Expand Down
87 changes: 63 additions & 24 deletions source/opaque.d.ts
Expand Up @@ -4,8 +4,8 @@ export type TagContainer<Token> = {
readonly [tag]: Token;
};

type MultiTagContainer<Token extends PropertyKey> = {
readonly [tag]: {[K in Token]: void};
type MultiTagContainer<Token extends PropertyKey, TagMetadata> = {
readonly [tag]: {[K in Token]: TagMetadata};
};

/**
Expand Down Expand Up @@ -113,19 +113,24 @@ type WillWork = UnwrapOpaque<Tagged<number, 'AccountNumber'>>; // number
@category Type
*/
export type UnwrapOpaque<OpaqueType extends TagContainer<unknown>> =
OpaqueType extends MultiTagContainer<string | number | symbol>
? RemoveAllTags<OpaqueType>
: OpaqueType extends Opaque<infer Type, OpaqueType[typeof tag]>
? Type
: OpaqueType;
OpaqueType extends Opaque<infer Type, OpaqueType[typeof tag]>
? Type
: OpaqueType;

/**
Attach 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.)
Attach 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.)
A type returned by `Tagged` can be passed to `Tagged` again, to create a type with multiple tags.
[Read more about tagged types.](https://medium.com/@KevinBGreene/surviving-the-typescript-ecosystem-branding-and-type-tagging-6cf6e516523d)
A tag 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.
A type `A` returned by `Tagged` is assignable to another type `B` returned by `Tagged` if and only if:
- the underlying (untagged) type of `A` is assignable to the underlying type of `B`;
- `A` contains at least all the tags `B` has;
- and the metadata type for each of `A`'s tags is assignable to the metadata type of `B`'s corresponding tag.
There have been several discussions about adding similar features to TypeScript. Unfortunately, nothing has (yet) moved forward:
- [Microsoft/TypeScript#202](https://github.com/microsoft/TypeScript/issues/202)
- [Microsoft/TypeScript#4895](https://github.com/microsoft/TypeScript/issues/4895)
Expand All @@ -151,18 +156,59 @@ function getMoneyForAccount(accountNumber: AccountNumber): AccountBalance {
getMoneyForAccount(createAccountNumber());
// But this won't, because it has to be explicitly passed as an `AccountNumber` type!
// Critically, you could not accidentally use an `AccountBalance` as an `AccountNumber`.
getMoneyForAccount(2);
// You can use opaque values like they aren't opaque too.
const accountNumber = createAccountNumber();
// You can also use tagged values like their underlying, untagged type.
// I.e., this will compile successfully because an `AccountNumber` can be used as a regular `number`.
// In this sense, the underlying base type is not hidden, which differentiates tagged types from opaque types in other languages.
const accountNumber = createAccountNumber() + 2;
```
// This will compile successfully.
const newAccountNumber = accountNumber + 2;
@example
```
import type {Tagged} from 'type-fest';
// You can apply multiple tags to a type by using `Tagged` repeatedly.
type Url = Tagged<string, 'URL'>;
type SpecialCacheKey = Tagged<Url, 'SpecialCacheKey'>;
// 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.
type SpecialCacheKey2 = Tagged<string, 'URL' | 'SpecialCacheKey'>;
```
@category Type
*/
export type Tagged<Type, Tag extends PropertyKey, TagMetadata = never> = Type & MultiTagContainer<Tag, TagMetadata>;

/**
Given a type and a tag, returns the metadata associated with that tag on that type.
In the example below, one could use `Tagged<string, 'JSON'>` could be used 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's not maximally useful, as it doesn't indicate what type of value will be produced on parse. `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.
This 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).
@example
```
import type {Tagged} from 'type-fest';
type JsonOf<T> = Tagged<string, 'JSON', T>;
function stringify<T>(it: T) {
return JSON.stringify(it) as JsonOf<T>;
}
function parse<T extends JsonOf<unknown>>(it: T) {
return JSON.parse(it) as GetTagMetadata<T, 'JSON'>;
}
const x = stringify({ hello: 'world' });
const parsed = parse(x); // The type of `parsed` is { hello: string }
```
@category Type
*/
export type Tagged<Type, Tag extends PropertyKey> = Type & MultiTagContainer<Tag>;
export type GetTagMetadata<Type extends MultiTagContainer<Tag, unknown>, Tag extends PropertyKey> = Type[typeof tag][Tag];

/**
Revert a tagged type back to its original type by removing the readonly `[tag]`.
Expand Down Expand Up @@ -192,14 +238,7 @@ type WontWork = UnwrapTagged<string>;
@category Type
*/
export type UnwrapTagged<TaggedType extends MultiTagContainer<PropertyKey>> =
RemoveAllTags<TaggedType>;

type RemoveAllTags<T> = T extends MultiTagContainer<infer ExistingTags>
? {
[ThisTag in ExistingTags]:
T extends Tagged<infer Type, ThisTag>
? RemoveAllTags<Type>
: never
}[ExistingTags]
: T;
export type UnwrapTagged<TaggedType extends MultiTagContainer<PropertyKey, any>> =
TaggedType extends Opaque<infer Type, TaggedType[typeof tag]>
? Type
: TaggedType;
26 changes: 24 additions & 2 deletions test-d/opaque.ts
@@ -1,5 +1,5 @@
import {expectAssignable, expectNotAssignable, expectNotType, expectType} from 'tsd';
import type {Opaque, UnwrapOpaque, Tagged, UnwrapTagged, SnakeCasedPropertiesDeep} from '../index';
import {expectAssignable, expectError, expectNotAssignable, expectNotType, expectType} from 'tsd';
import type {Opaque, UnwrapOpaque, Tagged, GetTagMetadata, UnwrapTagged, InvariantOf, SnakeCasedPropertiesDeep} from '../index';

type Value = Opaque<number, 'Value'>;

Expand Down Expand Up @@ -113,6 +113,28 @@ const unwrapped2 = 123 as PlainValueUnwrapTagged;
expectType<number>(unwrapped1);
expectType<number>(unwrapped2);

// Tags have no metadata by default
expectType<never>(undefined as unknown as GetTagMetadata<UrlString, 'URL'>);

// Metadata can be accurately recovered
type JsonOf<T> = Tagged<string, 'JSON', T>;
expectType<number>(JSON.parse('43') as GetTagMetadata<JsonOf<number>, 'JSON'>);

// It's a type error to try to get the metadata for a tag that doesn't exist on a type.
expectError('' as GetTagMetadata<UrlString, 'NonExistentTag'>);

// Tagged types should be covariant in their metadata type
expectAssignable<JsonOf<number>>('' as JsonOf<42>);
expectAssignable<JsonOf<number>>('' as JsonOf<number>);
expectNotAssignable<JsonOf<number>>('' as JsonOf<number | string>);

// InvariantOf should work with tag metadata.
expectNotAssignable<JsonOf<InvariantOf<number>>>('' as JsonOf<string | number>);
expectNotAssignable<JsonOf<InvariantOf<number>>>('' as JsonOf<42>);
expectAssignable<JsonOf<InvariantOf<number>>>(
'' as JsonOf<InvariantOf<number>>,
);

// Test for issue https://github.com/sindresorhus/type-fest/issues/643
type IdType = Opaque<number, 'test'>;
type TestSnakeObject = SnakeCasedPropertiesDeep<{testId: IdType}>;
Expand Down

0 comments on commit a134a83

Please sign in to comment.