Skip to content

Commit

Permalink
feat: add Tagged for composable tagged types
Browse files Browse the repository at this point in the history
  • Loading branch information
ethanresnick committed Aug 25, 2023
1 parent 1d4e122 commit 1b2a6bb
Show file tree
Hide file tree
Showing 3 changed files with 176 additions and 15 deletions.
2 changes: 1 addition & 1 deletion index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export type {PartialOnUndefinedDeep, PartialOnUndefinedDeepOptions} from './sour
export type {ReadonlyDeep} from './source/readonly-deep';
export type {LiteralUnion} from './source/literal-union';
export type {Promisable} from './source/promisable';
export type {Opaque, UnwrapOpaque} from './source/opaque';
export type {Opaque, UnwrapOpaque, Tagged, 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
122 changes: 110 additions & 12 deletions source/opaque.d.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
declare const tag: unique symbol;

declare type Tagged<Token> = {
declare type TagContainer<Token> = {
readonly [tag]: Token;
};

type MultiTagContainer<Token extends string | number | symbol> = {
readonly [tag]: {[K in Token]: void};
};

/**
Create an opaque type, which hides its internal details from the public, and can only be created by being used explicitly.
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.)
The generic type parameters can be anything.
Note 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.)
The generic type parameter can be anything. It doesn't have to be an object.
Also note that this implementation is limited to a single tag. If you want to allow multiple tags, use `Tagged` instead.
[Read more about opaque types.](https://codemix.com/opaque-types-in-javascript/)
[Read more about tagged types.](https://medium.com/@KevinBGreene/surviving-the-typescript-ecosystem-branding-and-type-tagging-6cf6e516523d)
There have been several discussions about adding this feature to TypeScript via the `opaque type` operator, similar to how Flow does it. Unfortunately, nothing has (yet) moved forward:
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#15408](https://github.com/Microsoft/TypeScript/issues/15408)
- [Microsoft/TypeScript#15807](https://github.com/Microsoft/TypeScript/issues/15807)
Expand Down Expand Up @@ -59,7 +67,7 @@ getMoneyForAccount(2);
// You can use opaque values like they aren't opaque too.
const accountNumber = createAccountNumber();
// This will not compile successfully.
// This will compile successfully.
const newAccountNumber = accountNumber + 2;
// As a side note, you can (and should) use recursive types for your opaque types to make them stronger and hopefully easier to type.
Expand All @@ -71,10 +79,10 @@ type Person = {
@category Type
*/
export type Opaque<Type, Token = unknown> = Type & Tagged<Token>;
export type Opaque<Type, Token = unknown> = Type & TagContainer<Token>;

/**
Revert an opaque type back to its original type by removing the readonly `[tag]`.
Revert an opaque or tagged type back to its original type by removing the readonly `[tag]`.
Why is this necessary?
Expand All @@ -97,11 +105,101 @@ const money = moneyByAccountType.SAVINGS; // TS error: Property 'SAVINGS' does n
// Attempting to pass an non-Opaque type to UnwrapOpaque will raise a type error.
type WontWork = UnwrapOpaque<string>;
// Using a Tagged type will work too.
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;

/**
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.)
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)
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)
- [Microsoft/TypeScript#33290](https://github.com/microsoft/TypeScript/pull/33290)
@example
```
import type {Tagged} from 'type-fest';
type AccountNumber = Tagged<number, 'AccountNumber'>;
type AccountBalance = Tagged<number, 'AccountBalance'>;
function createAccountNumber(): AccountNumber {
// As you can see, casting from a `number` (the underlying type being tagged) is allowed.
return 2 as AccountNumber;
}
function getMoneyForAccount(accountNumber: AccountNumber): AccountBalance {
return 4 as AccountBalance;
}
// This will compile successfully.
getMoneyForAccount(createAccountNumber());
// But this won't, because it has to be explicitly passed as an `AccountNumber` type!
getMoneyForAccount(2);
// You can use opaque values like they aren't opaque too.
const accountNumber = createAccountNumber();
// This will compile successfully.
const newAccountNumber = accountNumber + 2;
```
@category Type
*/
export type Tagged<Type, Tag extends string | number | symbol> = Type & MultiTagContainer<Tag>;

/**
Revert a tagged type back to its original type by removing the readonly `[tag]`.
Why is this necessary?
1. Use a `Tagged` type as object keys
2. Prevent TS4058 error: "Return type of exported function has or is using name X from external module Y but cannot be named"
@example
```
import type {Tagged, UnwrapTagged} from 'type-fest';
type AccountType = Tagged<'SAVINGS' | 'CHECKING', 'AccountType'>;
const moneyByAccountType: Record<UnwrapTagged<AccountType>, number> = {
SAVINGS: 99,
CHECKING: 0.1
};
// Without UnwrapTagged, the following expression would throw a type error.
const money = moneyByAccountType.SAVINGS; // TS error: Property 'SAVINGS' does not exist
// Attempting to pass an non-Tagged type to UnwrapTagged will raise a type error.
type WontWork = UnwrapTagged<string>;
```
@category Type
*/
export type UnwrapOpaque<OpaqueType extends Tagged<unknown>> =
OpaqueType extends Opaque<infer Type, OpaqueType[typeof tag]>
? Type
: OpaqueType;
export type UnwrapTagged<TaggedType extends MultiTagContainer<string | number | symbol>> =
RemoveAllTags<TaggedType>;

type RemoveAllTags<T> = T extends MultiTagContainer<infer ExistingTags>
? {
[ThisTag in ExistingTags]:
T extends Tagged<infer Type, ThisTag>
? RemoveAllTags<Type>
: never
}[ExistingTags]
: T;
67 changes: 65 additions & 2 deletions test-d/opaque.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {expectAssignable, expectNotAssignable, expectNotType, expectType} from 'tsd';
import type {Opaque, UnwrapOpaque} from '../index';
import type {Opaque, UnwrapOpaque, Tagged, UnwrapTagged} from '../index';

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

Expand All @@ -9,9 +9,12 @@ const value: Value = 2 as Value;
// The underlying type of the value is still a number.
expectAssignable<number>(value);

// You cannot modify an opaque value.
// You cannot modify an opaque value (and still get back an opaque value).
expectNotAssignable<Value>(value + 2);

// But you can modify one if you're just treating it as its underlying type.
expectAssignable<number>(value + 2);

type WithoutToken = Opaque<number>;
expectAssignable<WithoutToken>(2 as WithoutToken);

Expand Down Expand Up @@ -49,3 +52,63 @@ expectAssignable<PlainValue>(123);

const plainValue: PlainValue = 123 as PlainValue;
expectNotType<Value>(plainValue);

// UnwrapOpque should work even when the token _happens_ to make the Opaque type
// have the same underlying structure as a Tagged type.
expectType<number>(4 as UnwrapOpaque<Opaque<number, {x: void}>>);

// All the basic tests that apply to Opaque types should pass for Tagged types too.
// See rationale for each test in the Opaque tests above.
//
// Tests around not providing a token, which Tagged requires, or using non-
// `string | number | symbol` tags, which Tagged doesn't support, are excluded.
type TaggedValue = Tagged<number, 'Value'>;
type TaggedUUID = Tagged<string, 'UUID'>;

const taggedValue: TaggedValue = 2 as TaggedValue;
expectAssignable<number>(taggedValue);
expectNotAssignable<TaggedValue>(value + 2);
expectAssignable<number>(value + 2);

const userEntities2: Record<TaggedUUID, Foo> = {
['7dd4a16e-d5ee-454c-b1d0-71e23d9fa70b' as UUID]: {bar: 'John'},
['6ce31270-31eb-4a72-a9bf-43192d4ab436' as UUID]: {bar: 'Doe'},
};

const johnsId2 = '7dd4a16e-d5ee-454c-b1d0-71e23d9fa70b' as TaggedUUID;

const userJohn2 = userEntities2[johnsId2];
expectType<Foo>(userJohn2);

// Tagged types should support multiple tags,
// by intersection or repeated application of Tagged.
type AbsolutePath = Tagged<string, 'AbsolutePath'>;
type NormalizedPath = Tagged<string, 'NormalizedPath'>;
type NormalizedAbsolutePath = AbsolutePath & NormalizedPath;

type UrlString = Tagged<string, 'URL'>;
type SpecialCacheKey = Tagged<UrlString, 'SpecialCacheKey'>;

expectNotAssignable<NormalizedPath>('' as AbsolutePath);
expectNotAssignable<NormalizedAbsolutePath>('' as AbsolutePath);
expectAssignable<AbsolutePath>('' as NormalizedAbsolutePath);
expectAssignable<NormalizedPath>('' as NormalizedAbsolutePath);

expectNotAssignable<SpecialCacheKey>('' as UrlString);
expectAssignable<UrlString>('' as SpecialCacheKey);

// A tag that is a union type should be treated as multiple tags.
// This is the only practical-to-implement behavior, given how we're storing the tags.
// However, it's also arguably the desirable behavior, and it's what the TS team planned to implement:
// https://github.com/microsoft/TypeScript/pull/33290#issuecomment-529710519
expectAssignable<Tagged<number, 'Y'>>(4 as Tagged<number, 'X' | 'Y'>);

// UnwrapOpaque and UnwrapTagged both work on Tagged types.
type PlainValueUnwrapOpaque = UnwrapOpaque<TaggedValue>;
type PlainValueUnwrapTagged = UnwrapTagged<TaggedValue>;

const unwrapped1 = 123 as PlainValueUnwrapOpaque;
const unwrapped2 = 123 as PlainValueUnwrapTagged;

expectType<number>(unwrapped1);
expectType<number>(unwrapped2);

0 comments on commit 1b2a6bb

Please sign in to comment.