From a8e3231cf5fdfd43a9b5bcfaa0ce176df8965908 Mon Sep 17 00:00:00 2001 From: Robin Kneepkens Date: Mon, 18 Nov 2024 16:46:20 +0100 Subject: [PATCH] fix: Introduce small helper type that prompts TS to not forget branding. --- src/types/record.test.ts | 22 +++++++++++++++++++++- src/types/record.ts | 7 ++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/types/record.test.ts b/src/types/record.test.ts index 33ede93..4be047f 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'; @@ -214,3 +215,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..5637e87 100644 --- a/src/types/record.ts +++ b/src/types/record.ts @@ -33,7 +33,6 @@ export class RecordType< this.isDefaultName = !name; this.name = name || `Record<${keyType.name}, ${valueType.name}>`; } - /** {@inheritdoc BaseTypeImpl.typeValidator} */ protected typeValidator(input: unknown, options: ValidationOptions): Result { if (!unknownRecord.is(input)) { @@ -100,13 +99,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));