From fde2ed715c49ba9955b3d32d6d8f31d31ffb888b Mon Sep 17 00:00:00 2001 From: Robin Kneepkens Date: Mon, 18 Nov 2024 16:46:20 +0100 Subject: [PATCH 1/5] fix: Introduce small helper type that prompts TS to not forget branding. --- src/types/record.test.ts | 22 +++++++++++++++++++++- src/types/record.ts | 6 ++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/types/record.test.ts b/src/types/record.test.ts index 8cdbdae..29ecfdb 100644 --- a/src/types/record.test.ts +++ b/src/types/record.test.ts @@ -1,5 +1,6 @@ +import { expectTypeOf } from 'expect-type'; import { autoCast, autoCastAll } from '../autocast'; -import type { MessageDetails, The } from '../interfaces'; +import { type MessageDetails, type The } from '../interfaces'; import { createExample, defaultUsualSuspects, stripped, testTypeImpl } from '../testutils'; import { printKey, printValue } from '../utils'; import { object } from './interface'; @@ -244,3 +245,22 @@ testTypeImpl({ ], ], }); + +test('Branded types', () => { + // Branded values have a particular interaction with the Record type. + type BrandedString = The; + const BrandedString = string.withBrand('BrandedString'); + + type BrandedKVRecord = The; + const BrandedKVRecord = record('BrandedKVRecord', BrandedString, BrandedString); + + // Currently, branded types are not supported as Record key types. They are instead widened to the unbranded base type: + expectTypeOf().toEqualTypeOf>(); + // The problem with branded keytypes arises when trying to create a literal of the record type. + expectTypeOf( + // This `.literal()` would give a TS error because the `DeepUnbranding` can't deal with branded key types. + BrandedKVRecord.literal({ + a: 'b', + }), + ).toEqualTypeOf>(); +}); diff --git a/src/types/record.ts b/src/types/record.ts index d206332..c14667e 100644 --- a/src/types/record.ts +++ b/src/types/record.ts @@ -100,13 +100,15 @@ define( }, ); +/** Small helper type that somehow nudges TS compiler to not widen branded string and number types to their base type. */ +type Unwidened = T extends T ? T : never; /** * Note: record has strict validation by default, while type does not have strict validation, both are strict in construction though. TODO: document */ export function record( ...args: - | [name: string, keyType: BaseTypeImpl, valueType: BaseTypeImpl, strict?: boolean] - | [keyType: BaseTypeImpl, valueType: BaseTypeImpl, strict?: boolean] + | [name: string, keyType: BaseTypeImpl, valueType: BaseTypeImpl>, strict?: boolean] + | [keyType: BaseTypeImpl, valueType: BaseTypeImpl>, strict?: boolean] ): TypeImpl, KeyType, BaseTypeImpl, ValueType>> { const [name, keyType, valueType, strict] = decodeOptionalName(args); return createType(new RecordType(acceptNumberLikeKey(keyType), valueType, name, strict)); From d7afe07a029b15cb550c53353b9f991ddfb2ea32 Mon Sep 17 00:00:00 2001 From: Robin Kneepkens Date: Mon, 18 Nov 2024 17:08:27 +0100 Subject: [PATCH 2/5] Updated API docs. --- etc/types.api.md | 4 +++- markdown/types.record.md | 10 +++++----- src/types/intersection.ts | 5 +---- src/types/number.ts | 2 +- src/types/record.ts | 7 +------ src/types/union.ts | 5 +---- 6 files changed, 12 insertions(+), 21 deletions(-) diff --git a/etc/types.api.md b/etc/types.api.md index d0ef588..8fd5594 100644 --- a/etc/types.api.md +++ b/etc/types.api.md @@ -437,8 +437,10 @@ export type PropertyInfo = Type> = { type: T; }; +// Warning: (ae-forgotten-export) The symbol "Unwidened" needs to be exported by the entry point index.d.ts +// // @public -export function record(...args: [name: string, keyType: BaseTypeImpl, valueType: BaseTypeImpl, strict?: boolean] | [keyType: BaseTypeImpl, valueType: BaseTypeImpl, strict?: boolean]): TypeImpl, KeyType, BaseTypeImpl, ValueType>>; +export function record(...args: [name: string, keyType: BaseTypeImpl, valueType: BaseTypeImpl>, strict?: boolean] | [keyType: BaseTypeImpl, valueType: BaseTypeImpl>, strict?: boolean]): TypeImpl, KeyType, BaseTypeImpl, ValueType>>; // @public export class RecordType, KeyType extends number | string, ValueTypeImpl extends BaseTypeImpl, ValueType, ResultType extends Record = Record> extends BaseTypeImpl { diff --git a/markdown/types.record.md b/markdown/types.record.md index 8d66536..9e3d918 100644 --- a/markdown/types.record.md +++ b/markdown/types.record.md @@ -11,16 +11,16 @@ Note: record has strict validation by default, while type does not have strict v ```typescript declare function record( ...args: - | [name: string, keyType: BaseTypeImpl, valueType: BaseTypeImpl, strict?: boolean] - | [keyType: BaseTypeImpl, valueType: BaseTypeImpl, strict?: boolean] + | [name: string, keyType: BaseTypeImpl, valueType: BaseTypeImpl>, strict?: boolean] + | [keyType: BaseTypeImpl, valueType: BaseTypeImpl>, strict?: boolean] ): TypeImpl, KeyType, BaseTypeImpl, ValueType>>; ``` ## Parameters -| Parameter | Type | Description | -| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | -| args | \[name: string, keyType: [BaseTypeImpl](./types.basetypeimpl.md)<KeyType>, valueType: [BaseTypeImpl](./types.basetypeimpl.md)<ValueType>, strict?: boolean\] \| \[keyType: [BaseTypeImpl](./types.basetypeimpl.md)<KeyType>, valueType: [BaseTypeImpl](./types.basetypeimpl.md)<ValueType>, strict?: boolean\] | | +| Parameter | Type | Description | +| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| args | \[name: string, keyType: [BaseTypeImpl](./types.basetypeimpl.md)<KeyType>, valueType: [BaseTypeImpl](./types.basetypeimpl.md)<Unwidened<ValueType>>, strict?: boolean\] \| \[keyType: [BaseTypeImpl](./types.basetypeimpl.md)<KeyType>, valueType: [BaseTypeImpl](./types.basetypeimpl.md)<Unwidened<ValueType>>, strict?: boolean\] | | **Returns:** diff --git a/src/types/intersection.ts b/src/types/intersection.ts index 85c0647..15880a1 100644 --- a/src/types/intersection.ts +++ b/src/types/intersection.ts @@ -43,10 +43,7 @@ export class IntersectionType = { function selectBound(key: T, current: NumberTypeConfig, update: NumberTypeConfig): Bound { const exclKey = `${key}Exclusive` as const; - const onlyTheBound = (c: NumberTypeConfig) => ({ [key]: c[key], [exclKey]: c[exclKey] }) as Bound; + const onlyTheBound = (c: NumberTypeConfig) => ({ [key]: c[key], [exclKey]: c[exclKey] } as Bound); const currentPosition: number | undefined = current[key] ?? current[exclKey]; if (currentPosition == null) return onlyTheBound(update); diff --git a/src/types/record.ts b/src/types/record.ts index c14667e..c1741e4 100644 --- a/src/types/record.ts +++ b/src/types/record.ts @@ -23,12 +23,7 @@ export class RecordType< /** {@inheritdoc BaseTypeImpl.typeConfig} */ readonly typeConfig: undefined; - constructor( - readonly keyType: KeyTypeImpl, - readonly valueType: ValueTypeImpl, - name?: string, - readonly strict = true, - ) { + constructor(readonly keyType: KeyTypeImpl, readonly valueType: ValueTypeImpl, name?: string, readonly strict = true) { super(); this.isDefaultName = !name; this.name = name || `Record<${keyType.name}, ${valueType.name}>`; diff --git a/src/types/union.ts b/src/types/union.ts index 8d7428f..d162526 100644 --- a/src/types/union.ts +++ b/src/types/union.ts @@ -31,10 +31,7 @@ export class UnionType< /** {@inheritdoc BaseTypeImpl.typeConfig} */ readonly typeConfig: undefined; - constructor( - readonly types: Types, - name?: string, - ) { + constructor(readonly types: Types, name?: string) { super(); this.isDefaultName = !name; this.name = name || types.map(type => bracketsIfNeeded(type.name, '|')).join(' | '); From 72e3c8ed65ca1f74ee160428d61ef2b98541a409 Mon Sep 17 00:00:00 2001 From: Robin Kneepkens Date: Mon, 18 Nov 2024 18:12:50 +0100 Subject: [PATCH 3/5] Forgotten export --- etc/types.api.md | 5 +++-- markdown/types.md | 1 + markdown/types.record.md | 6 +++--- markdown/types.unwidened.md | 13 +++++++++++++ src/types/record.ts | 2 +- 5 files changed, 21 insertions(+), 6 deletions(-) create mode 100644 markdown/types.unwidened.md diff --git a/etc/types.api.md b/etc/types.api.md index 8fd5594..b21a811 100644 --- a/etc/types.api.md +++ b/etc/types.api.md @@ -437,8 +437,6 @@ export type PropertyInfo = Type> = { type: T; }; -// Warning: (ae-forgotten-export) The symbol "Unwidened" needs to be exported by the entry point index.d.ts -// // @public export function record(...args: [name: string, keyType: BaseTypeImpl, valueType: BaseTypeImpl>, strict?: boolean] | [keyType: BaseTypeImpl, valueType: BaseTypeImpl>, strict?: boolean]): TypeImpl, KeyType, BaseTypeImpl, ValueType>>; @@ -625,6 +623,9 @@ export type unknownRecord = Record; // @public export const unknownRecord: Type; +// @public +export type Unwidened = T extends T ? T : never; + // @public export type ValidationDetails = { type: BaseTypeImpl; diff --git a/markdown/types.md b/markdown/types.md index e5f9258..c3a97d7 100644 --- a/markdown/types.md +++ b/markdown/types.md @@ -139,6 +139,7 @@ Runtime type-validation with derived TypeScript types. | [UnbrandValues](./types.unbrandvalues.md) | | | [unknownArray](./types.unknownarray.md) | Built-in validator that accepts all arrays. | | [unknownRecord](./types.unknownrecord.md) | Built-in validator that accepts all objects (null is not accepted). | +| [Unwidened](./types.unwidened.md) | Small helper type that somehow nudges TS compiler to not widen branded string and number types to their base type. | | [ValidationDetails](./types.validationdetails.md) | Information about the performed validation for error-reporting. | | [ValidationMode](./types.validationmode.md) | The validation mode to use. | | [ValidationResult](./types.validationresult.md) | The possible return values inside validation and constraint functions. | diff --git a/markdown/types.record.md b/markdown/types.record.md index 9e3d918..5fcca66 100644 --- a/markdown/types.record.md +++ b/markdown/types.record.md @@ -18,9 +18,9 @@ declare function record( ## Parameters -| Parameter | Type | Description | -| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | -| args | \[name: string, keyType: [BaseTypeImpl](./types.basetypeimpl.md)<KeyType>, valueType: [BaseTypeImpl](./types.basetypeimpl.md)<Unwidened<ValueType>>, strict?: boolean\] \| \[keyType: [BaseTypeImpl](./types.basetypeimpl.md)<KeyType>, valueType: [BaseTypeImpl](./types.basetypeimpl.md)<Unwidened<ValueType>>, strict?: boolean\] | | +| Parameter | Type | Description | +| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- | +| args | \[name: string, keyType: [BaseTypeImpl](./types.basetypeimpl.md)<KeyType>, valueType: [BaseTypeImpl](./types.basetypeimpl.md)<[Unwidened](./types.unwidened.md)<ValueType>>, strict?: boolean\] \| \[keyType: [BaseTypeImpl](./types.basetypeimpl.md)<KeyType>, valueType: [BaseTypeImpl](./types.basetypeimpl.md)<[Unwidened](./types.unwidened.md)<ValueType>>, strict?: boolean\] | | **Returns:** diff --git a/markdown/types.unwidened.md b/markdown/types.unwidened.md new file mode 100644 index 0000000..a3f7944 --- /dev/null +++ b/markdown/types.unwidened.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [@skunkteam/types](./types.md) > [Unwidened](./types.unwidened.md) + +## Unwidened type + +Small helper type that somehow nudges TS compiler to not widen branded string and number types to their base type. + +**Signature:** + +```typescript +type Unwidened = T extends T ? T : never; +``` diff --git a/src/types/record.ts b/src/types/record.ts index c1741e4..4892d90 100644 --- a/src/types/record.ts +++ b/src/types/record.ts @@ -96,7 +96,7 @@ define( ); /** Small helper type that somehow nudges TS compiler to not widen branded string and number types to their base type. */ -type Unwidened = T extends T ? T : never; +export type Unwidened = T extends T ? T : never; /** * Note: record has strict validation by default, while type does not have strict validation, both are strict in construction though. TODO: document */ From 8fe80ba2bca0e9a5a8cffe5cae9bc794d10943b2 Mon Sep 17 00:00:00 2001 From: Robin Kneepkens Date: Mon, 18 Nov 2024 18:23:06 +0100 Subject: [PATCH 4/5] Weird stuff with formatting or smth. --- src/types/intersection.ts | 5 ++++- src/types/number.ts | 2 +- src/types/record.ts | 7 ++++++- src/types/union.ts | 5 ++++- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/types/intersection.ts b/src/types/intersection.ts index 15880a1..85c0647 100644 --- a/src/types/intersection.ts +++ b/src/types/intersection.ts @@ -43,7 +43,10 @@ export class IntersectionType = { function selectBound(key: T, current: NumberTypeConfig, update: NumberTypeConfig): Bound { const exclKey = `${key}Exclusive` as const; - const onlyTheBound = (c: NumberTypeConfig) => ({ [key]: c[key], [exclKey]: c[exclKey] } as Bound); + const onlyTheBound = (c: NumberTypeConfig) => ({ [key]: c[key], [exclKey]: c[exclKey] }) as Bound; const currentPosition: number | undefined = current[key] ?? current[exclKey]; if (currentPosition == null) return onlyTheBound(update); diff --git a/src/types/record.ts b/src/types/record.ts index 4892d90..6658a79 100644 --- a/src/types/record.ts +++ b/src/types/record.ts @@ -23,7 +23,12 @@ export class RecordType< /** {@inheritdoc BaseTypeImpl.typeConfig} */ readonly typeConfig: undefined; - constructor(readonly keyType: KeyTypeImpl, readonly valueType: ValueTypeImpl, name?: string, readonly strict = true) { + constructor( + readonly keyType: KeyTypeImpl, + readonly valueType: ValueTypeImpl, + name?: string, + readonly strict = true, + ) { super(); this.isDefaultName = !name; this.name = name || `Record<${keyType.name}, ${valueType.name}>`; diff --git a/src/types/union.ts b/src/types/union.ts index d162526..8d7428f 100644 --- a/src/types/union.ts +++ b/src/types/union.ts @@ -31,7 +31,10 @@ export class UnionType< /** {@inheritdoc BaseTypeImpl.typeConfig} */ readonly typeConfig: undefined; - constructor(readonly types: Types, name?: string) { + constructor( + readonly types: Types, + name?: string, + ) { super(); this.isDefaultName = !name; this.name = name || types.map(type => bracketsIfNeeded(type.name, '|')).join(' | '); From 0f3344797b02dca5c7134185950da00bb98c379f Mon Sep 17 00:00:00 2001 From: Robin Kneepkens Date: Wed, 21 May 2025 16:27:18 +0300 Subject: [PATCH 5/5] Review comments --- etc/types.api.md | 22 +- markdown/types.basetypeimpl.md | 1 - markdown/types.basetypeimpl.withdefault.md | 27 --- markdown/types.checkoneormore.md | 23 --- markdown/types.isoneormore.md | 27 --- markdown/types.md | 4 - markdown/types.oneormore.md | 4 - markdown/types.record.md | 10 +- markdown/types.unwidened.md | 13 -- markdown/types.visitor.md | 3 +- .../types.visitor.visitintersectiontype.md | 21 -- markdown/types.visitor.visitobjectliketype.md | 21 ++ markdown/types.visitor.visitobjecttype.md | 21 -- markdown/types.withdefaultoptions.clone.md | 17 -- markdown/types.withdefaultoptions.md | 20 -- markdown/types.withdefaultoptions.name.md | 13 -- src/interfaces.ts | 6 +- src/types/record.test.ts | 188 ++++++++++++++++-- src/types/record.ts | 25 ++- 19 files changed, 226 insertions(+), 240 deletions(-) delete mode 100644 markdown/types.basetypeimpl.withdefault.md delete mode 100644 markdown/types.checkoneormore.md delete mode 100644 markdown/types.isoneormore.md delete mode 100644 markdown/types.unwidened.md delete mode 100644 markdown/types.visitor.visitintersectiontype.md create mode 100644 markdown/types.visitor.visitobjectliketype.md delete mode 100644 markdown/types.visitor.visitobjecttype.md delete mode 100644 markdown/types.withdefaultoptions.clone.md delete mode 100644 markdown/types.withdefaultoptions.md delete mode 100644 markdown/types.withdefaultoptions.name.md diff --git a/etc/types.api.md b/etc/types.api.md index b21a811..cc405ec 100644 --- a/etc/types.api.md +++ b/etc/types.api.md @@ -89,7 +89,6 @@ export abstract class BaseTypeImpl implements withBrand(name: BrandName): Type, TypeConfig>; withConfig(name: BrandName, newConfig: TypeConfig): Type, TypeConfig>; withConstraint(name: BrandName, constraint: Validator): Type, TypeConfig>; - withDefault(...args: [value: DeepUnbranded] | [name: string, value: DeepUnbranded] | [options: WithDefaultOptions, value: DeepUnbranded]): this; withName(name: string): this; withParser(...args: [newConstructor: (i: unknown) => unknown] | [name: string, newConstructor: (i: unknown) => unknown] | [options: ParserOptions, newConstructor: (i: unknown) => unknown]): this; withValidation(validation: Validator): this; @@ -110,9 +109,6 @@ export type Branded = T extends WithBrands(arr: T[]): OneOrMore; - // @public export function createType>(impl: Impl, override?: Partial | 'typeValidator' | 'typeParser' | 'customValidators', PropertyDescriptor>>): TypeImpl; @@ -236,9 +232,6 @@ export class IntersectionType>; } -// @public -export function isOneOrMore(arr: T[]): arr is OneOrMore; - // @public export function isType(value: unknown): value is Type; @@ -438,7 +431,7 @@ export type PropertyInfo = Type> = { }; // @public -export function record(...args: [name: string, keyType: BaseTypeImpl, valueType: BaseTypeImpl>, strict?: boolean] | [keyType: BaseTypeImpl, valueType: BaseTypeImpl>, strict?: boolean]): TypeImpl, KeyType, BaseTypeImpl, ValueType>>; +export function record(...args: [name: string, keyType: BaseTypeImpl, valueType: BaseTypeImpl, strict?: boolean] | [keyType: BaseTypeImpl, valueType: BaseTypeImpl, strict?: boolean]): TypeImpl, KeyType, BaseTypeImpl, ValueType>>; // @public export class RecordType, KeyType extends number | string, ValueTypeImpl extends BaseTypeImpl, ValueType, ResultType extends Record = Record> extends BaseTypeImpl { @@ -623,9 +616,6 @@ export type unknownRecord = Record; // @public export const unknownRecord: Type; -// @public -export type Unwidened = T extends T ? T : never; - // @public export type ValidationDetails = { type: BaseTypeImpl; @@ -678,15 +668,13 @@ export interface Visitor { // (undocumented) visitCustomType(type: BaseTypeImpl): R; // (undocumented) - visitIntersectionType(type: IntersectionType>>): R; - // (undocumented) visitKeyofType(type: KeyofType, any>): R; // (undocumented) visitLiteralType(type: LiteralType): R; // (undocumented) visitNumberType(type: BaseTypeImpl): R; // (undocumented) - visitObjectType(type: InterfaceType): R; + visitObjectLikeType(type: BaseObjectLikeTypeImpl): R; // (undocumented) visitRecordType(type: RecordType, number | string, BaseTypeImpl, unknown>): R; // (undocumented) @@ -711,12 +699,6 @@ export type WithBrands = T & { }; }; -// @public -export interface WithDefaultOptions { - clone?: boolean; - name?: string; -} - // @public export type Writable = { -readonly [P in keyof T]: T[P]; diff --git a/markdown/types.basetypeimpl.md b/markdown/types.basetypeimpl.md index 73bd8e6..f1a152e 100644 --- a/markdown/types.basetypeimpl.md +++ b/markdown/types.basetypeimpl.md @@ -54,7 +54,6 @@ All type-implementations must extend this base class. Use [createType()](./types | [withBrand(name)](./types.basetypeimpl.withbrand.md) | | Create a new instance of this Type with the given name. | | [withConfig(name, newConfig)](./types.basetypeimpl.withconfig.md) | | Create a new instance of this Type with the additional type-specific config, such as min/max values. | | [withConstraint(name, constraint)](./types.basetypeimpl.withconstraint.md) | | Create a new type based on the current type and use the given constraint function as validation. | -| [withDefault(args)](./types.basetypeimpl.withdefault.md) | | Define a new type with the same specs, but with the given default value. | | [withName(name)](./types.basetypeimpl.withname.md) | | Create a new instance of this Type with the given name. | | [withParser(args)](./types.basetypeimpl.withparser.md) | | Define a new type with the same specs, but with the given parser and an optional new name. | | [withValidation(validation)](./types.basetypeimpl.withvalidation.md) | | Clone the type with the added validation. | diff --git a/markdown/types.basetypeimpl.withdefault.md b/markdown/types.basetypeimpl.withdefault.md deleted file mode 100644 index d171fa8..0000000 --- a/markdown/types.basetypeimpl.withdefault.md +++ /dev/null @@ -1,27 +0,0 @@ - - -[Home](./index.md) > [@skunkteam/types](./types.md) > [BaseTypeImpl](./types.basetypeimpl.md) > [withDefault](./types.basetypeimpl.withdefault.md) - -## BaseTypeImpl.withDefault() method - -Define a new type with the same specs, but with the given default value. - -**Signature:** - -```typescript -withDefault(...args: [value: DeepUnbranded] | [name: string, value: DeepUnbranded] | [options: WithDefaultOptions, value: DeepUnbranded]): this; -``` - -## Parameters - -| Parameter | Type | Description | -| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | -| args | \[value: [DeepUnbranded](./types.deepunbranded.md)<ResultType>\] \| \[name: string, value: [DeepUnbranded](./types.deepunbranded.md)<ResultType>\] \| \[options: [WithDefaultOptions](./types.withdefaultoptions.md), value: [DeepUnbranded](./types.deepunbranded.md)<ResultType>\] | | - -**Returns:** - -this - -## Remarks - -This is a convenient method that adds a simple parser that resolves to the given `value` whenever the `input` to the parser is `undefined`. diff --git a/markdown/types.checkoneormore.md b/markdown/types.checkoneormore.md deleted file mode 100644 index 083dbfa..0000000 --- a/markdown/types.checkoneormore.md +++ /dev/null @@ -1,23 +0,0 @@ - - -[Home](./index.md) > [@skunkteam/types](./types.md) > [checkOneOrMore](./types.checkoneormore.md) - -## checkOneOrMore() function - -Returns the original array if and only if it has at least one element. - -**Signature:** - -```typescript -declare function checkOneOrMore(arr: T[]): OneOrMore; -``` - -## Parameters - -| Parameter | Type | Description | -| --------- | ----- | ----------- | -| arr | T\[\] | | - -**Returns:** - -[OneOrMore](./types.oneormore.md)<T> diff --git a/markdown/types.isoneormore.md b/markdown/types.isoneormore.md deleted file mode 100644 index fb16eb8..0000000 --- a/markdown/types.isoneormore.md +++ /dev/null @@ -1,27 +0,0 @@ - - -[Home](./index.md) > [@skunkteam/types](./types.md) > [isOneOrMore](./types.isoneormore.md) - -## isOneOrMore() function - -Type guard for `OneOrMore` - -**Signature:** - -```typescript -declare function isOneOrMore(arr: T[]): arr is OneOrMore; -``` - -## Parameters - -| Parameter | Type | Description | -| --------- | ----- | ----------- | -| arr | T\[\] | | - -**Returns:** - -arr is [OneOrMore](./types.oneormore.md)<T> - -## Remarks - -This checks if the array has at least one element. diff --git a/markdown/types.md b/markdown/types.md index c3a97d7..a7185f9 100644 --- a/markdown/types.md +++ b/markdown/types.md @@ -35,10 +35,8 @@ Runtime type-validation with derived TypeScript types. | [autoCast(type)](./types.autocast.md) | Returns the same type, but with an auto-casting default parser installed. | | [autoCastAll(type)](./types.autocastall.md) | Create a recursive autocasting version of the given type. | | [booleanAutoCaster(input)](./types.booleanautocaster.md) | | -| [checkOneOrMore(arr)](./types.checkoneormore.md) | Returns the original array if and only if it has at least one element. | | [createType(impl, override)](./types.createtype.md) | Create a Type from the given type-implementation. | | [intersection(args)](./types.intersection.md) | Intersect the given types. | -| [isOneOrMore(arr)](./types.isoneormore.md) | Type guard for OneOrMore | | [isType(value)](./types.istype.md) | Type-guard that asserts that a given value is a Type. | | [keyof(args)](./types.keyof.md) | | | [literal(value)](./types.literal.md) | | @@ -73,7 +71,6 @@ Runtime type-validation with derived TypeScript types. | [TypeLink](./types.typelink.md) | An object that has an associated TypeScript type. | | [ValidationOptions](./types.validationoptions.md) | | | [Visitor](./types.visitor.md) | Interface for a visitor that is accepted by all types (classic visitor-pattern). | -| [WithDefaultOptions](./types.withdefaultoptions.md) | Options that can be passed to [BaseTypeImpl.withDefault()](./types.basetypeimpl.withdefault.md). | ## Variables @@ -139,7 +136,6 @@ Runtime type-validation with derived TypeScript types. | [UnbrandValues](./types.unbrandvalues.md) | | | [unknownArray](./types.unknownarray.md) | Built-in validator that accepts all arrays. | | [unknownRecord](./types.unknownrecord.md) | Built-in validator that accepts all objects (null is not accepted). | -| [Unwidened](./types.unwidened.md) | Small helper type that somehow nudges TS compiler to not widen branded string and number types to their base type. | | [ValidationDetails](./types.validationdetails.md) | Information about the performed validation for error-reporting. | | [ValidationMode](./types.validationmode.md) | The validation mode to use. | | [ValidationResult](./types.validationresult.md) | The possible return values inside validation and constraint functions. | diff --git a/markdown/types.oneormore.md b/markdown/types.oneormore.md index 36e6b41..5b9fb7f 100644 --- a/markdown/types.oneormore.md +++ b/markdown/types.oneormore.md @@ -11,7 +11,3 @@ An Array with at least one element. ```typescript type OneOrMore = [T, ...T[]]; ``` - -## Remarks - -Note that this type does not disable the mutable methods of the array, which may still invalidate the non-emptiness of the array. diff --git a/markdown/types.record.md b/markdown/types.record.md index 5fcca66..8d66536 100644 --- a/markdown/types.record.md +++ b/markdown/types.record.md @@ -11,16 +11,16 @@ Note: record has strict validation by default, while type does not have strict v ```typescript declare function record( ...args: - | [name: string, keyType: BaseTypeImpl, valueType: BaseTypeImpl>, strict?: boolean] - | [keyType: BaseTypeImpl, valueType: BaseTypeImpl>, strict?: boolean] + | [name: string, keyType: BaseTypeImpl, valueType: BaseTypeImpl, strict?: boolean] + | [keyType: BaseTypeImpl, valueType: BaseTypeImpl, strict?: boolean] ): TypeImpl, KeyType, BaseTypeImpl, ValueType>>; ``` ## Parameters -| Parameter | Type | Description | -| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- | -| args | \[name: string, keyType: [BaseTypeImpl](./types.basetypeimpl.md)<KeyType>, valueType: [BaseTypeImpl](./types.basetypeimpl.md)<[Unwidened](./types.unwidened.md)<ValueType>>, strict?: boolean\] \| \[keyType: [BaseTypeImpl](./types.basetypeimpl.md)<KeyType>, valueType: [BaseTypeImpl](./types.basetypeimpl.md)<[Unwidened](./types.unwidened.md)<ValueType>>, strict?: boolean\] | | +| Parameter | Type | Description | +| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| args | \[name: string, keyType: [BaseTypeImpl](./types.basetypeimpl.md)<KeyType>, valueType: [BaseTypeImpl](./types.basetypeimpl.md)<ValueType>, strict?: boolean\] \| \[keyType: [BaseTypeImpl](./types.basetypeimpl.md)<KeyType>, valueType: [BaseTypeImpl](./types.basetypeimpl.md)<ValueType>, strict?: boolean\] | | **Returns:** diff --git a/markdown/types.unwidened.md b/markdown/types.unwidened.md deleted file mode 100644 index a3f7944..0000000 --- a/markdown/types.unwidened.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [@skunkteam/types](./types.md) > [Unwidened](./types.unwidened.md) - -## Unwidened type - -Small helper type that somehow nudges TS compiler to not widen branded string and number types to their base type. - -**Signature:** - -```typescript -type Unwidened = T extends T ? T : never; -``` diff --git a/markdown/types.visitor.md b/markdown/types.visitor.md index ae689bd..7c0593a 100644 --- a/markdown/types.visitor.md +++ b/markdown/types.visitor.md @@ -19,11 +19,10 @@ interface Visitor | [visitArrayType(type)](./types.visitor.visitarraytype.md) | | | [visitBooleanType(type)](./types.visitor.visitbooleantype.md) | | | [visitCustomType(type)](./types.visitor.visitcustomtype.md) | | -| [visitIntersectionType(type)](./types.visitor.visitintersectiontype.md) | | | [visitKeyofType(type)](./types.visitor.visitkeyoftype.md) | | | [visitLiteralType(type)](./types.visitor.visitliteraltype.md) | | | [visitNumberType(type)](./types.visitor.visitnumbertype.md) | | -| [visitObjectType(type)](./types.visitor.visitobjecttype.md) | | +| [visitObjectLikeType(type)](./types.visitor.visitobjectliketype.md) | | | [visitRecordType(type)](./types.visitor.visitrecordtype.md) | | | [visitStringType(type)](./types.visitor.visitstringtype.md) | | | [visitUnionType(type)](./types.visitor.visituniontype.md) | | diff --git a/markdown/types.visitor.visitintersectiontype.md b/markdown/types.visitor.visitintersectiontype.md deleted file mode 100644 index dd070de..0000000 --- a/markdown/types.visitor.visitintersectiontype.md +++ /dev/null @@ -1,21 +0,0 @@ - - -[Home](./index.md) > [@skunkteam/types](./types.md) > [Visitor](./types.visitor.md) > [visitIntersectionType](./types.visitor.visitintersectiontype.md) - -## Visitor.visitIntersectionType() method - -**Signature:** - -```typescript -visitIntersectionType(type: IntersectionType>>): R; -``` - -## Parameters - -| Parameter | Type | Description | -| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | -| type | [IntersectionType](./types.intersectiontype.md)<[OneOrMore](./types.oneormore.md)<[BaseObjectLikeTypeImpl](./types.baseobjectliketypeimpl.md)<unknown>>> | | - -**Returns:** - -R diff --git a/markdown/types.visitor.visitobjectliketype.md b/markdown/types.visitor.visitobjectliketype.md new file mode 100644 index 0000000..5b890c6 --- /dev/null +++ b/markdown/types.visitor.visitobjectliketype.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [@skunkteam/types](./types.md) > [Visitor](./types.visitor.md) > [visitObjectLikeType](./types.visitor.visitobjectliketype.md) + +## Visitor.visitObjectLikeType() method + +**Signature:** + +```typescript +visitObjectLikeType(type: BaseObjectLikeTypeImpl): R; +``` + +## Parameters + +| Parameter | Type | Description | +| --------- | ---------------------------------------------------------------------------------- | ----------- | +| type | [BaseObjectLikeTypeImpl](./types.baseobjectliketypeimpl.md)<unknown> | | + +**Returns:** + +R diff --git a/markdown/types.visitor.visitobjecttype.md b/markdown/types.visitor.visitobjecttype.md deleted file mode 100644 index 9ccd76f..0000000 --- a/markdown/types.visitor.visitobjecttype.md +++ /dev/null @@ -1,21 +0,0 @@ - - -[Home](./index.md) > [@skunkteam/types](./types.md) > [Visitor](./types.visitor.md) > [visitObjectType](./types.visitor.visitobjecttype.md) - -## Visitor.visitObjectType() method - -**Signature:** - -```typescript -visitObjectType(type: InterfaceType): R; -``` - -## Parameters - -| Parameter | Type | Description | -| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | -| type | [InterfaceType](./types.interfacetype.md)<[Properties](./types.properties.md), [unknownRecord](./types.unknownrecord.md)> | | - -**Returns:** - -R diff --git a/markdown/types.withdefaultoptions.clone.md b/markdown/types.withdefaultoptions.clone.md deleted file mode 100644 index c646d5b..0000000 --- a/markdown/types.withdefaultoptions.clone.md +++ /dev/null @@ -1,17 +0,0 @@ - - -[Home](./index.md) > [@skunkteam/types](./types.md) > [WithDefaultOptions](./types.withdefaultoptions.md) > [clone](./types.withdefaultoptions.clone.md) - -## WithDefaultOptions.clone property - -Whether to clone the given value on each use. - -**Signature:** - -```typescript -clone?: boolean; -``` - -## Remarks - -When `true` (the default), on each use the value will be cloned using `structuredClone` to prevent subtle bugs because of object reuse. diff --git a/markdown/types.withdefaultoptions.md b/markdown/types.withdefaultoptions.md deleted file mode 100644 index b439dd6..0000000 --- a/markdown/types.withdefaultoptions.md +++ /dev/null @@ -1,20 +0,0 @@ - - -[Home](./index.md) > [@skunkteam/types](./types.md) > [WithDefaultOptions](./types.withdefaultoptions.md) - -## WithDefaultOptions interface - -Options that can be passed to [BaseTypeImpl.withDefault()](./types.basetypeimpl.withdefault.md). - -**Signature:** - -```typescript -interface WithDefaultOptions -``` - -## Properties - -| Property | Modifiers | Type | Description | -| --------------------------------------------- | --------- | ------- | ---------------------------------------------------------- | -| [clone?](./types.withdefaultoptions.clone.md) | | boolean | _(Optional)_ Whether to clone the given value on each use. | -| [name?](./types.withdefaultoptions.name.md) | | string | _(Optional)_ The new name to use in error messages. | diff --git a/markdown/types.withdefaultoptions.name.md b/markdown/types.withdefaultoptions.name.md deleted file mode 100644 index b665feb..0000000 --- a/markdown/types.withdefaultoptions.name.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [@skunkteam/types](./types.md) > [WithDefaultOptions](./types.withdefaultoptions.md) > [name](./types.withdefaultoptions.name.md) - -## WithDefaultOptions.name property - -The new name to use in error messages. - -**Signature:** - -```typescript -name?: string; -``` diff --git a/src/interfaces.ts b/src/interfaces.ts index c43c95d..76e66cb 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -221,10 +221,14 @@ export type DeepUnbranded = T extends readonly [any, ...any[]] | readonly [] : T extends ReadonlyArray ? ReadonlyArray> : T extends Record - ? UnbrandValues> + ? UnbrandRecordLike> : Unbranded; export type UnbrandValues = { [P in keyof T]: DeepUnbranded }; +/** Check if the key type of the record-like structure (could also be an object) is branded. In that case, explicitely unbrand the keytype */ +export type UnbrandRecordLike = keyof T extends WithBrands | WithBrands + ? Record, DeepUnbranded> + : UnbrandValues; /** * The properties of an object type. diff --git a/src/types/record.test.ts b/src/types/record.test.ts index 29ecfdb..171b326 100644 --- a/src/types/record.test.ts +++ b/src/types/record.test.ts @@ -1,11 +1,12 @@ import { expectTypeOf } from 'expect-type'; +import { WithBrands } from '..'; import { autoCast, autoCastAll } from '../autocast'; -import { type MessageDetails, type The } from '../interfaces'; +import { DeepUnbranded, type MessageDetails, type The } from '../interfaces'; import { createExample, defaultUsualSuspects, stripped, testTypeImpl } from '../testutils'; import { printKey, printValue } from '../utils'; import { object } from './interface'; import { keyof } from './keyof'; -import { literal } from './literal'; +import { literal, undefinedType } from './literal'; import { int, number } from './number'; import { record } from './record'; import { string } from './string'; @@ -246,21 +247,172 @@ testTypeImpl({ ], }); -test('Branded types', () => { - // Branded values have a particular interaction with the Record type. +// Branded keys and values have a particular interaction with the Record type. +describe('Branded entries', () => { type BrandedString = The; - const BrandedString = string.withBrand('BrandedString'); - - type BrandedKVRecord = The; - const BrandedKVRecord = record('BrandedKVRecord', BrandedString, BrandedString); - - // Currently, branded types are not supported as Record key types. They are instead widened to the unbranded base type: - expectTypeOf().toEqualTypeOf>(); - // The problem with branded keytypes arises when trying to create a literal of the record type. - expectTypeOf( - // This `.literal()` would give a TS error because the `DeepUnbranding` can't deal with branded key types. - BrandedKVRecord.literal({ - a: 'b', - }), - ).toEqualTypeOf>(); + const BrandedString = string.withBrand('A'); + test('Branded strings', () => { + type BrandedKVRecord = The; + const BrandedKVRecord = record('BrandedKVRecord', BrandedString, BrandedString); + + // Strict typing on the Key and Value type of the record: + expectTypeOf().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + + const brandedVal = BrandedString('branded'); + const regularVal = String('abc'); + + // Regular strings used as keys and values don't match the stricter type: + expectTypeOf({ [regularVal]: regularVal }).not.toEqualTypeOf(); + expectTypeOf({ [regularVal]: regularVal }).toEqualTypeOf>(); + + // Also normal: + expectTypeOf({ [regularVal]: brandedVal }).toEqualTypeOf>(); + + // Using `brandedVal` at an index position like this actually calls `toString()` on it, widening the type to `string`. + // See: https://basarat.gitbook.io/typescript/type-system/index-signatures + // So this is expected behaviour: + expectTypeOf({ [brandedVal]: regularVal }).toEqualTypeOf>(); + expectTypeOf({ [brandedVal]: brandedVal }).toEqualTypeOf>(); + + // Works with explicit type notations though: + const explicitlyTypedValue: BrandedKVRecord = { [brandedVal]: brandedVal }; + expectTypeOf(explicitlyTypedValue).toEqualTypeOf>(); + + // Literal allows for unbranded entry: + expectTypeOf(BrandedKVRecord.literal({ a: 'b' })).toEqualTypeOf(); + }); + + type BrandedNumber = The; + const BrandedNumber = number.withBrand('B'); + test('Branded numbers', () => { + type BrandedKVRecord = The; + const BrandedKVRecord = record('BrandedKVRecord', BrandedNumber, BrandedNumber); + + // Strict typing on the Key and Value type of the record: + expectTypeOf().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + + const brandedVal = BrandedNumber(1); + const regularVal = Number(1); + + // Regular numbers used as keys and values don't match the stricter type: + expectTypeOf({ [regularVal]: regularVal }).not.toEqualTypeOf(); + expectTypeOf({ [regularVal]: regularVal }).toEqualTypeOf>(); + + // Also normal: + expectTypeOf({ [regularVal]: brandedVal }).toEqualTypeOf>(); + + // Using `brandedVal` at an index position like this also widens the type to `number`: + expectTypeOf({ [brandedVal]: regularVal }).toEqualTypeOf>(); + expectTypeOf({ [brandedVal]: brandedVal }).toEqualTypeOf>(); + + // Works with explicit type notations though: + const explicitlyTypedValue: BrandedKVRecord = { [brandedVal]: brandedVal }; + expectTypeOf(explicitlyTypedValue).toEqualTypeOf>(); + + // Literal allows for unbranded entry: + expectTypeOf(BrandedKVRecord.literal({ 1: 2 })).toEqualTypeOf(); + }); + + describe('Unions over branded values', () => { + test('Homogeneous branded types', () => { + type OtherBrandedString = The; + const OtherBrandedString = string.withBrand('C'); + + type BrandedStringUnion = The; + const BrandedStringUnion = union('BrandedStringUnion', [BrandedString, OtherBrandedString]); + expectTypeOf().toEqualTypeOf(); + + type HomogeneousStringsRecord = The; + const HomogeneousStringsRecord = record('HomogeneousStringsRecord', BrandedString, BrandedStringUnion); + + // This works as expected: + expectTypeOf().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf(HomogeneousStringsRecord.literal({ a: 'b' })).toEqualTypeOf(); + }); + + test('Inhomogeneous branded types', () => { + type MixedTypeBranding = The; + const MixedTypeBranding = union('MixedTypeBranding', [BrandedString, BrandedNumber]); + + // At this point everything is fine. We have a union of two differently branded types: + expectTypeOf().toEqualTypeOf(); + + type MixedTypeRecord = The; + const MixedTypeRecord = record('MixedTypeRecord', BrandedString, MixedTypeBranding); + + // Here we have some unexpected behaviour: + expectTypeOf().not.toEqualTypeOf>(); + // The branding information gets lost in a weird way: + expectTypeOf().toEqualTypeOf< + Record< + BrandedString, // Key stays branded... + WithBrands | WithBrands // ...but the values lose the specific brand names. + > + >(); + + // Unbranding works: + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf(MixedTypeRecord.literal({ a: 'b', c: 4 })).toEqualTypeOf< + Record | WithBrands> + >(); + }); + + test('Mixing branded and undefinedType', () => { + type MixedBranding = The; + const MixedBranding = union('MixedBranding', [BrandedNumber, undefinedType]); + + // At this point everything is fine. We have a union over a branded number and unbranded `undefined`: + expectTypeOf().toEqualTypeOf(); + + type MixedBrandedRecord = The; + const MixedBrandedRecord = record('MixedBrandedRecord', BrandedString, MixedBranding); + + // This also doesn't work as expected: + expectTypeOf().not.toEqualTypeOf>(); + // Branding is now completely lost on the value type: + expectTypeOf().toEqualTypeOf>(); + + // DeepUnbranded works: + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf(MixedBrandedRecord.literal({ a: 2, c: undefined })).toEqualTypeOf>(); + }); + + test('Mixing branded and unbranded types', () => { + type MixedBranding = The; + const MixedBranding = union('MixedBranding', [BrandedNumber, string]); + + // Union over a branded number and unbranded `string`: + expectTypeOf().toEqualTypeOf(); + + type MixedBrandedRecord = The; + const MixedBrandedRecord = record('MixedBrandedRecord', BrandedString, MixedBranding); + + // This works (though whether it's expected at this point remains questionable...): + expectTypeOf().toEqualTypeOf>(); + + // DeepUnbranded works: + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf(MixedBrandedRecord.literal({ a: 2, c: 'weird' })).toEqualTypeOf>(); + }); + }); + + test('Branded object value', () => { + type BrandedObject = The; + const BrandedObject = object({ a: string }).withBrand('BrandedObject'); + + type BrandedVRecord = The; + const BrandedVRecord = record('BrandedVRecord', string, BrandedObject.or(literal('whatever'))); + + expectTypeOf().toEqualTypeOf>(); + + const unbranded = { a: 'abc' }; + const branded = BrandedObject.literal(unbranded); + const someString = String('abc'); + + expectTypeOf({ [someString]: unbranded }).not.toMatchTypeOf(); + expectTypeOf({ [someString]: branded }).toMatchTypeOf(); + }); }); diff --git a/src/types/record.ts b/src/types/record.ts index 6658a79..880618a 100644 --- a/src/types/record.ts +++ b/src/types/record.ts @@ -100,15 +100,34 @@ define( }, ); -/** Small helper type that somehow nudges TS compiler to not widen branded string and number types to their base type. */ +/** + * Small helper type that nudges the TS compiler to not widen branded string and number types to their base type. + */ export type Unwidened = T extends T ? T : never; + /** * Note: record has strict validation by default, while type does not have strict validation, both are strict in construction though. TODO: document + * + * Warning: mixed unions of branded types as `valueType` behave unpredictably - the brand names get lost: + * ```ts + * type MixedValueType = The; + * const MixedValueType = record('MixedValueType', string, union([number.withBrand('A'), string.withBrand('B')]); + * // MixedValueType = Record | WithBrands> + * // instead of: Record | WithBrands> + * ``` + * + * Warning: unions of branded types with `undefinedType` as `valueType` behave unpredictably - the branding dissapears completely: + * ```ts + * type WithUndefined = The; + * const WithUndefined = record('WithUndefined', string, union([number.withBrand('A'), undefinedType]); + * // WithUndefined = Record + * // instead of: Record | undefined> + * ``` */ export function record( ...args: - | [name: string, keyType: BaseTypeImpl, valueType: BaseTypeImpl>, strict?: boolean] - | [keyType: BaseTypeImpl, valueType: BaseTypeImpl>, strict?: boolean] + | [name: string, keyType: BaseTypeImpl>, valueType: BaseTypeImpl>, strict?: boolean] + | [keyType: BaseTypeImpl>, valueType: BaseTypeImpl>, strict?: boolean] ): TypeImpl, KeyType, BaseTypeImpl, ValueType>> { const [name, keyType, valueType, strict] = decodeOptionalName(args); return createType(new RecordType(acceptNumberLikeKey(keyType), valueType, name, strict));