Skip to content

Commit

Permalink
fix: Introduce small helper type that prompts TS to not forget branding.
Browse files Browse the repository at this point in the history
  • Loading branch information
untio11 committed Nov 18, 2024
1 parent 7da4ed3 commit a8e3231
Show file tree
Hide file tree
Showing 2 changed files with 25 additions and 4 deletions.
22 changes: 21 additions & 1 deletion src/types/record.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -214,3 +215,22 @@ testTypeImpl({
],
],
});

test('Branded types', () => {
// Branded values have a particular interaction with the Record type.
type BrandedString = The<typeof BrandedString>;
const BrandedString = string.withBrand('BrandedString');

type BrandedKVRecord = The<typeof BrandedKVRecord>;
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<BrandedKVRecord>().toEqualTypeOf<Record<string, BrandedString>>();
// 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<Record<string, BrandedString>>();
});
7 changes: 4 additions & 3 deletions src/types/record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ResultType> {
if (!unknownRecord.is(input)) {
Expand Down Expand Up @@ -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> = 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<KeyType extends number | string, ValueType>(
...args:
| [name: string, keyType: BaseTypeImpl<KeyType>, valueType: BaseTypeImpl<ValueType>, strict?: boolean]
| [keyType: BaseTypeImpl<KeyType>, valueType: BaseTypeImpl<ValueType>, strict?: boolean]
| [name: string, keyType: BaseTypeImpl<KeyType>, valueType: BaseTypeImpl<Unwidened<ValueType>>, strict?: boolean]
| [keyType: BaseTypeImpl<KeyType>, valueType: BaseTypeImpl<Unwidened<ValueType>>, strict?: boolean]
): TypeImpl<RecordType<BaseTypeImpl<KeyType>, KeyType, BaseTypeImpl<ValueType>, ValueType>> {
const [name, keyType, valueType, strict] = decodeOptionalName(args);
return createType(new RecordType(acceptNumberLikeKey(keyType), valueType, name, strict));
Expand Down

0 comments on commit a8e3231

Please sign in to comment.