From 3588e736e5dc4426e1a406f27e980de4b5399738 Mon Sep 17 00:00:00 2001 From: Ethan Resnick Date: Fri, 25 Aug 2023 02:38:10 -0400 Subject: [PATCH] feat: add Tagged for composable tagged types --- index.d.ts | 2 +- readme.md | 6 +- source/exact.d.ts | 6 +- source/invariant-of.d.ts | 4 +- source/opaque.d.ts | 122 +++++++++++++++++++++++++++++++++++---- test-d/exact.ts | 13 ++++- test-d/opaque.ts | 67 ++++++++++++++++++++- 7 files changed, 198 insertions(+), 22 deletions(-) diff --git a/index.d.ts b/index.d.ts index 85140b9d2..7bedfa8a9 100644 --- a/index.d.ts +++ b/index.d.ts @@ -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'; diff --git a/readme.md b/readme.md index 62e464d4c..684679968 100644 --- a/readme.md +++ b/readme.md @@ -128,8 +128,10 @@ Click the type names for complete docs. - [`PartialOnUndefinedDeep`](source/partial-on-undefined-deep.d.ts) - Create a deep version of another type where all keys accepting `undefined` type are set to optional. - [`ReadonlyDeep`](source/readonly-deep.d.ts) - Create a deeply immutable version of an `object`/`Map`/`Set`/`Array` type. Use [`Readonly`](https://www.typescriptlang.org/docs/handbook/utility-types.html#readonlytype) if you only need one level deep. - [`LiteralUnion`](source/literal-union.d.ts) - Create a union type by combining primitive types and literal types without sacrificing auto-completion in IDEs for the literal type part of the union. Workaround for [Microsoft/TypeScript#29729](https://github.com/Microsoft/TypeScript/issues/29729). -- [`Opaque`](source/opaque.d.ts) - Create an [opaque type](https://codemix.com/opaque-types-in-javascript/). -- [`UnwrapOpaque`](source/opaque.d.ts) - Revert an [opaque type](https://codemix.com/opaque-types-in-javascript/) back to its original type. +- [`Tagged`](source/opaque.d.ts) - Create a [tagged type](https://medium.com/@KevinBGreene/surviving-the-typescript-ecosystem-branding-and-type-tagging-6cf6e516523d) that can support [multiple tags](https://github.com/sindresorhus/type-fest/issues/665) if needed. +- [`UnwrapTagged`](source/opaque.d.ts) - Get the untagged portion of a tagged type created with `Tagged`. +- [`Opaque`](source/opaque.d.ts) - Create a [tagged type](https://medium.com/@KevinBGreene/surviving-the-typescript-ecosystem-branding-and-type-tagging-6cf6e516523d). This implementation only supports a single tag. +- [`UnwrapOpaque`](source/opaque.d.ts) - Get the untagged portion of a tagged type created with `Opaque` or `Tagged`. - [`InvariantOf`](source/invariant-of.d.ts) - Create an [invariant type](https://basarat.gitbook.io/typescript/type-system/type-compatibility#footnote-invariance), which is a type that does not accept supertypes and subtypes. - [`SetOptional`](source/set-optional.d.ts) - Create a type that makes the given keys optional. - [`SetReadonly`](source/set-readonly.d.ts) - Create a type that makes the given keys readonly. diff --git a/source/exact.d.ts b/source/exact.d.ts index 6c435eb8d..0af9595e2 100644 --- a/source/exact.d.ts +++ b/source/exact.d.ts @@ -1,5 +1,5 @@ import type {KeysOfUnion, ArrayElement, ObjectValue} from './internal'; -import type {Opaque} from './opaque'; +import type {Opaque, TagContainer} from './opaque'; import type {IsEqual} from './is-equal'; /** @@ -56,7 +56,7 @@ export type Exact = : ParameterType extends unknown[] ? Array, ArrayElement>> // In TypeScript, Array is a subtype of ReadonlyArray, so always test Array before ReadonlyArray. : ParameterType extends readonly unknown[] ? ReadonlyArray, ArrayElement>> - // For Opaque types, internal details are hidden from public, so let's leave it as is. - : ParameterType extends Opaque ? ParameterType + // Leave tagged types as-is. We could try to make the untagged part Exact, and just leave the tag as-is, but that seems to create instanitation excessively deep errors. + : ParameterType extends TagContainer ? ParameterType : ParameterType extends object ? ExactObject : ParameterType; diff --git a/source/invariant-of.d.ts b/source/invariant-of.d.ts index 6f7ac9209..2fa821e46 100644 --- a/source/invariant-of.d.ts +++ b/source/invariant-of.d.ts @@ -1,5 +1,7 @@ import type {Opaque} from './opaque'; +declare const invariantBrand: unique symbol; + /** Create an [invariant type](https://basarat.gitbook.io/typescript/type-system/type-compatibility#footnote-invariance), which is a type that does not accept supertypes and subtypes. @@ -73,4 +75,4 @@ keyOfInvariantFooBar(invariantOf(fooBarBaz)); // Error: Argument of type 'Invari @category Type */ -export type InvariantOf = Opaque Type>; +export type InvariantOf = Type & {[invariantBrand]: (_: Type) => Type}; diff --git a/source/opaque.d.ts b/source/opaque.d.ts index aafbca55a..89f721877 100644 --- a/source/opaque.d.ts +++ b/source/opaque.d.ts @@ -1,17 +1,25 @@ declare const tag: unique symbol; -declare type Tagged = { +declare type TagContainer = { readonly [tag]: Token; }; +type MultiTagContainer = { + 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) @@ -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. @@ -71,10 +79,10 @@ type Person = { @category Type */ -export type Opaque = Type & Tagged; +export type Opaque = Type & TagContainer; /** -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? @@ -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; + +// Using a Tagged type will work too. +type WillWork = UnwrapOpaque>; // number +``` + +@category Type +*/ +export type UnwrapOpaque> = + OpaqueType extends MultiTagContainer + ? RemoveAllTags + : OpaqueType extends Opaque + ? 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; +type AccountBalance = Tagged; + +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 & MultiTagContainer; + +/** +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, 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; ``` @category Type */ -export type UnwrapOpaque> = - OpaqueType extends Opaque - ? Type - : OpaqueType; +export type UnwrapTagged> = +RemoveAllTags; + +type RemoveAllTags = T extends MultiTagContainer + ? { + [ThisTag in ExistingTags]: + T extends Tagged + ? RemoveAllTags + : never + }[ExistingTags] + : T; diff --git a/test-d/exact.ts b/test-d/exact.ts index 44018b0de..a8ab124f2 100644 --- a/test-d/exact.ts +++ b/test-d/exact.ts @@ -1,3 +1,4 @@ +import {expectError} from 'tsd'; import type {Exact, Opaque} from '../index'; { // Spec - string type @@ -353,7 +354,7 @@ import type {Exact, Opaque} from '../index'; } } -// Spec - special test case for Opaque type +// Spec - special test case for Opaque types // @see https://github.com/sindresorhus/type-fest/issues/508 { type SpecialName = Opaque; @@ -390,6 +391,16 @@ import type {Exact, Opaque} from '../index'; }); } +// Spec - test the above for tagged types too. +{ + type TaggedNumber = Opaque; + + const fn = >(arguments_: T) => arguments_; + + fn({a: 1 as TaggedNumber}); + expectError(fn({a: 1 as TaggedNumber, b: true})); +} + // Spec - special test case for deep optional union // https://github.com/sindresorhus/type-fest/issues/545 { diff --git a/test-d/opaque.ts b/test-d/opaque.ts index c5c5843c4..e71a8844a 100644 --- a/test-d/opaque.ts +++ b/test-d/opaque.ts @@ -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; @@ -9,9 +9,12 @@ const value: Value = 2 as Value; // The underlying type of the value is still a number. expectAssignable(value); -// You cannot modify an opaque value. +// You cannot modify an opaque value (and still get back an opaque value). expectNotAssignable(value + 2); +// But you can modify one if you're just treating it as its underlying type. +expectAssignable(value + 2); + type WithoutToken = Opaque; expectAssignable(2 as WithoutToken); @@ -49,3 +52,63 @@ expectAssignable(123); const plainValue: PlainValue = 123 as PlainValue; expectNotType(plainValue); + +// UnwrapOpque should work even when the token _happens_ to make the Opaque type +// have the same underlying structure as a Tagged type. +expectType(4 as UnwrapOpaque>); + +// 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; +type TaggedUUID = Tagged; + +const taggedValue: TaggedValue = 2 as TaggedValue; +expectAssignable(taggedValue); +expectNotAssignable(value + 2); +expectAssignable(value + 2); + +const userEntities2: Record = { + ['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(userJohn2); + +// Tagged types should support multiple tags, +// by intersection or repeated application of Tagged. +type AbsolutePath = Tagged; +type NormalizedPath = Tagged; +type NormalizedAbsolutePath = AbsolutePath & NormalizedPath; + +type UrlString = Tagged; +type SpecialCacheKey = Tagged; + +expectNotAssignable('' as AbsolutePath); +expectNotAssignable('' as AbsolutePath); +expectAssignable('' as NormalizedAbsolutePath); +expectAssignable('' as NormalizedAbsolutePath); + +expectNotAssignable('' as UrlString); +expectAssignable('' 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>(4 as Tagged); + +// UnwrapOpaque and UnwrapTagged both work on Tagged types. +type PlainValueUnwrapOpaque = UnwrapOpaque; +type PlainValueUnwrapTagged = UnwrapTagged; + +const unwrapped1 = 123 as PlainValueUnwrapOpaque; +const unwrapped2 = 123 as PlainValueUnwrapTagged; + +expectType(unwrapped1); +expectType(unwrapped2);