From bdad576e936a1ba8afa777def49b388ec96d3a57 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Sun, 28 Jul 2024 19:56:01 -0400 Subject: [PATCH] fix(types): make `assign` return type more accurate + add `Assign` type --- src/object/assign.ts | 134 ++++++++++++++++++- src/types.ts | 241 ++++++++++++++++++++++++++++++---- tests/object/assign.test-d.ts | 197 +++++++++++++++++++++++++++ tsconfig.json | 2 +- 4 files changed, 539 insertions(+), 35 deletions(-) create mode 100644 tests/object/assign.test-d.ts diff --git a/src/object/assign.ts b/src/object/assign.ts index d46f33676..f1ecd56bf 100644 --- a/src/object/assign.ts +++ b/src/object/assign.ts @@ -1,4 +1,12 @@ -import { isPlainObject } from 'radashi' +import { + isPlainObject, + type BoxedPrimitive, + type BuiltInType, + type CustomClass, + type IsExactType, + type OptionalKeys, + type RequiredKeys, +} from 'radashi' /** * Create a copy of the first object, and then merge the second object @@ -14,12 +22,12 @@ import { isPlainObject } from 'radashi' * // => { a: 1, b: 2, c: 3, p: { a: 4, b: 5 } } * ``` */ -export function assign>( - initial: X, - override: X, -): X { +export function assign< + TInitial extends Record, + TOverride extends Record, +>(initial: TInitial, override: TOverride): Assign { if (!initial || !override) { - return initial ?? override ?? {} + return (initial ?? override ?? {}) as any } const proto = Object.getPrototypeOf(initial) const merged = proto @@ -33,3 +41,117 @@ export function assign>( } return merged } + +/** + * The return type for `assign`. + * + * It recursively merges object types that are not native objects. The + * root objects are always merged. + * + * @see https://radashi-org.github.io/reference/object/assign + */ +export type Assign< + TInitial extends object, + TOverride extends object, +> = TInitial extends any + ? TOverride extends any + ? SimplifyMutable< + Omit & + Omit & + (Pick< + TInitial, + keyof TInitial & keyof TOverride + > extends infer TConflictInitial + ? Pick< + TOverride, + keyof TInitial & keyof TOverride + > extends infer TConflictOverride + ? { + [K in RequiredKeys]: AssignDeep< + TConflictInitial[K & keyof TConflictInitial], + TConflictOverride[K] + > + } & { + [K in RequiredKeys & + OptionalKeys]: AssignDeep< + TConflictInitial[K], + TConflictOverride[K], + true + > + } & { + [K in OptionalKeys & + OptionalKeys]?: AssignDeep< + TConflictInitial[K], + TConflictOverride[K], + true + > + } + : unknown + : unknown) + > + : never + : never + +/** + * Mimic the `Simplify` type and also remove `readonly` modifiers. + */ +type SimplifyMutable = {} & { + -readonly [P in keyof T]: T[P] +} + +/** + * This represents a value that should only be replaced if it exists + * as an initial value; never deeply assigned into. + */ +type AtomicValue = BuiltInType | CustomClass | BoxedPrimitive + +/** + * Handle mixed types when merging nested plain objects. + * + * For example, if the type `TOverride` includes both `string` and `{ n: + * number }` in a union, `AssignDeep` will treat `string` as + * unmergeable and `{ n: number }` as mergeable. + */ +type AssignDeep = + | never // <-- ignore me! + /** + * When a native type is found in TInitial, it will only exist in + * the result type if the override is optional. + */ + | (TInitial extends AtomicValue + ? IsOptional extends true + ? TInitial + : never + : never) + /** + * When a native type is found in TOverride, it will always exists + * in the result type. + */ + | (TOverride extends AtomicValue ? TOverride : never) + /** + * Deep assignment is handled in this branch. + * + * 1. Exclude any native types from TInitial and TOverride + * 2. If a non-native object type is not found in TInitial, simply + * replace TInitial (or use "A | B" if the override is optional) + * 3. For each non-native object type in TOverride, deep assign to + * every non-native object in TInitial + * 4. For each non-object type in TOverride, simply replace TInitial + * (or use "A | B" if the override is optional) + */ + | (Exclude extends infer TOverride // 1. + ? Exclude> extends infer TInitial + ? [Extract] extends [never] // 2. + ? TOverride | (IsOptional extends true ? TInitial : never) + : TInitial extends object + ? TOverride extends object + ? IsExactType extends true + ? TOverride + : Assign // 3. + : // 4. + TOverride | (IsOptional extends true ? TInitial : never) + : + | Extract + | (IsOptional extends true ? TInitial : never) + : never + : never) diff --git a/src/types.ts b/src/types.ts index 20baf2c40..234204254 100644 --- a/src/types.ts +++ b/src/types.ts @@ -53,34 +53,6 @@ export type CompatibleProperty = [T] extends [Any] : never }[keyof BoxedPrimitive] -/** - * Coerce a primitive type to its boxed equivalent. - * - * @example - * ```ts - * type A = BoxedPrimitive - * // ^? String - * type B = BoxedPrimitive - * // ^? Number - * ``` - */ -export type BoxedPrimitive = T extends string - ? // biome-ignore lint/complexity/noBannedTypes: - String - : T extends number - ? // biome-ignore lint/complexity/noBannedTypes: - Number - : T extends boolean - ? // biome-ignore lint/complexity/noBannedTypes: - Boolean - : T extends bigint - ? // biome-ignore lint/complexity/noBannedTypes: - BigInt - : T extends symbol - ? // biome-ignore lint/complexity/noBannedTypes: - Symbol - : T - /** * A value that can be reliably compared with JavaScript comparison * operators (e.g. `>`, `>=`, etc). @@ -124,3 +96,216 @@ export type Intersect = (U extends any ? (k: U) => void : never) extends ( * @see https://github.com/microsoft/TypeScript/issues/15300 */ export type Simplify = {} & { [P in keyof T]: T[P] } + +/** + * Get all properties **not using** the `?:` type operator. + */ +export type RequiredKeys = T extends any + ? keyof T extends infer K + ? K extends keyof T + ? Omit extends T + ? never + : K + : never + : never + : never + +/** + * Get all properties using the `?:` type operator. + */ +export type OptionalKeys = T extends any + ? keyof T extends infer K + ? K extends keyof T + ? Omit extends T + ? K + : never + : never + : never + : never + +/** + * Resolves to `true` if `Left` and `Right` are exactly the same type. + * + * Otherwise false. + */ +export type IsExactType = [Left] extends [Any] + ? [Right] extends [Any] + ? true + : false + : (() => U extends Left ? 1 : 0) extends () => U extends Right ? 1 : 0 + ? true + : false + +export type Primitive = + | number + | string + | boolean + | symbol + | bigint + | null + | undefined + | void + +/** + * Coerce a primitive type to its boxed equivalent. + * + * @example + * ```ts + * type A = BoxedPrimitive + * // ^? String + * type B = BoxedPrimitive + * // ^? Number + * ``` + */ +export type BoxedPrimitive = T extends string + ? // biome-ignore lint: + String + : T extends number + ? // biome-ignore lint: + Number + : T extends boolean + ? // biome-ignore lint: + Boolean + : T extends bigint + ? // biome-ignore lint: + BigInt + : T extends symbol + ? // biome-ignore lint: + Symbol + : never + +export type TypedArray = + | Int8Array + | Uint8Array + | Uint8ClampedArray + | Int16Array + | Uint16Array + | Int32Array + | Uint32Array + | Float32Array + | Float64Array + | BigInt64Array + | BigUint64Array + | DataView + | ArrayBuffer + | SharedArrayBuffer + +/** + * Add your own classes to this regitsry by extending its interface + * with what's called “declaration merging” in TypeScript. + * + * All property types in this registry type may be treated specially + * by any of Radashi's complex types. For example, `assign` will avoid + * merging with types in this registry. + */ +// biome-ignore lint: Preserve `interface` type. +export interface CustomClassRegistry {} + +/** + * This type represents any custom class that was "registered" through + * the `CustomClassRegistry` type. + */ +export type CustomClass = CustomClassRegistry[keyof CustomClassRegistry] + +/** + * These types are implemented natively. + * + * Note that boxed primitives like `Boolean` (different from + * `boolean`) are not included, because `boolean extends Boolean ? 1 : + * 0` resolves to 1. + */ +export type BuiltInType = + | ES2021.BuiltInType + | WebAPI.BuiltInType + | NodeJS.BuiltInType + +// Start at ES2020, since they are the typings used by Radashi. +declare namespace ES2020 { + // Note: Don't include subtypes of types already listed here. + type BuiltInType = + | Primitive + | Promise + | Date + | RegExp + | Error + | readonly any[] + | ReadonlyMap + | ReadonlySet + | WeakMap + | WeakSet + | TypedArray + // biome-ignore lint: Support the Function type. + | Function +} + +declare namespace ES2021 { + // Note: Don't include subtypes of types already listed here. + type BuiltInType = + | ES2020.BuiltInType + | GlobalObjectType<'FinalizationRegistry'> + | GlobalObjectType<'WeakRef'> +} + +declare namespace NodeJS { + type BuiltInType = GlobalObjectType<'Buffer'> +} + +declare namespace WebAPI { + // Note: Don't include subtypes of types already listed here. + type BuiltInType = + | GlobalObjectType<'AbortController'> + | GlobalObjectType<'AbortSignal'> + | GlobalObjectType<'Blob'> + | GlobalObjectType<'Body'> + | GlobalObjectType<'CompressionStream'> + | GlobalObjectType<'Crypto'> + | GlobalObjectType<'CustomEvent'> + | GlobalObjectType<'DecompressionStream'> + | GlobalObjectType<'Event'> + | GlobalObjectType<'EventTarget'> // <-- Watch out for subtypes of this. + | GlobalObjectType<'FormData'> + | GlobalObjectType<'Headers'> + | GlobalObjectType<'MessageChannel'> + | GlobalObjectType<'Navigator'> + | GlobalObjectType<'ReadableStream'> + | GlobalObjectType<'ReadableStreamBYOBReader'> + | GlobalObjectType<'ReadableStreamDefaultController'> + | GlobalObjectType<'ReadableStreamDefaultReader'> + | GlobalObjectType<'SubtleCrypto'> + | GlobalObjectType<'TextDecoder'> + | GlobalObjectType<'TextDecoderStream'> + | GlobalObjectType<'TextEncoder'> + | GlobalObjectType<'TextEncoderStream'> + | GlobalObjectType<'TransformStream'> + | GlobalObjectType<'TransformStreamDefaultController'> + | GlobalObjectType<'URL'> + | GlobalObjectType<'URLSearchParams'> + | GlobalObjectType<'WebSocket'> + | GlobalObjectType<'WritableStream'> + | GlobalObjectType<'WritableStreamDefaultController'> + | GlobalObjectType<'WritableStreamDefaultWriter'> + | WebDocumentAPI.BuiltInType +} + +declare namespace WebDocumentAPI { + // Note: Don't include subtypes of types already listed here. + type BuiltInType = + | GlobalObjectType<'Node'> + | GlobalObjectType<'NodeList'> + | GlobalObjectType<'NodeIterator'> + | GlobalObjectType<'HTMLCollection'> + | GlobalObjectType<'CSSStyleDeclaration'> + | GlobalObjectType<'DOMStringList'> + | GlobalObjectType<'DOMTokenList'> +} + +// Infer an object type from a global constructor that is +// environment-specific. This helps avoid including unsupported types +// (according to tsconfig "lib" property), which can break things. +type GlobalObjectType = [Identifier] extends [Any] + ? never + : keyof Identifier extends never + ? never + : typeof globalThis extends { [P in Identifier]: any } + ? InstanceType<(typeof globalThis)[Identifier]> + : never diff --git a/tests/object/assign.test-d.ts b/tests/object/assign.test-d.ts new file mode 100644 index 000000000..b7512f0f0 --- /dev/null +++ b/tests/object/assign.test-d.ts @@ -0,0 +1,197 @@ +import { assign } from 'radashi' + +// For testing `assign` with custom class instances. +class Data { + constructor(public data: number) {} +} + +// This declaration tells `assign` to never merge plain objects into +// our Data class. +declare module 'radashi' { + interface CustomClassRegistry { + Data: Data + } +} + +describe('assign', () => { + test('assign property with native object', () => { + const initial = {} as { a: RegExp } + const override = {} as { a: Date } + const result = assign(initial, override) + + expectTypeOf(result).toEqualTypeOf<{ + a: Date + }>() + }) + + test('assign property with primitive value', () => { + const initial = {} as { a: number } + const override = {} as { a: string } + const result = assign(initial, override) + + expectTypeOf(result).toEqualTypeOf<{ + a: string + }>() + }) + + test('assign property with optional primitive value', () => { + const initial = {} as { a: number } + const override = {} as { a?: string } + const result = assign(initial, override) + + expectTypeOf(result).toEqualTypeOf<{ + a: number | string | undefined + }>() + }) + + test('assign optional property with primitive value', () => { + const initial = {} as { a?: number } + const override = {} as { a: string } + const result = assign(initial, override) + + expectTypeOf(result).toEqualTypeOf<{ + a: string + }>() + }) + + test('assign optional property with optional primitive value', () => { + const initial = {} as { a?: number } + const override = {} as { a?: string } + const result = assign(initial, override) + + expectTypeOf(result).toEqualTypeOf<{ + a?: string | number + }>() + }) + + test('assign Map property with Map', () => { + const initial = {} as { a: Map } + const override = {} as { a: Map } + const result = assign(initial, override) + + expectTypeOf(result).toEqualTypeOf<{ + a: Map + }>() + }) + + describe('nested arrays', () => { + test('assign array property with array of primitives', () => { + const initial = {} as { a: number[] } + const override = {} as { a: string[] } + const result = assign(initial, override) + + expectTypeOf(result).toEqualTypeOf<{ + a: string[] + }>() + }) + + test('assign array property with array of objects', () => { + const initial = {} as { a: { b: number }[] } + const override = {} as { a: { b: string }[] } + const result = assign(initial, override) + + expectTypeOf(result).toEqualTypeOf<{ + a: { b: string }[] + }>() + }) + }) + + describe('nested objects', () => { + test('assign Map property with object', () => { + const initial = {} as { a: Map } + const override = {} as { a: { b: string } } + const result = assign(initial, override) + + expectTypeOf(result).toEqualTypeOf<{ + a: { b: string } + }>() + }) + + test('assign object property with object', () => { + const initial = {} as { a: { b: number; c: number } } + const override = {} as { a: { b: string; b2?: string } } + const result = assign(initial, override) + + expectTypeOf(result).toEqualTypeOf<{ + a: { b: string; b2?: string; c: number } + }>() + }) + + test('assign optional object property with object', () => { + const initial = {} as { a?: { b: number; c: number } | null | undefined } + const override = {} as { a: { b: string } } + const result = assign(initial, override) + + // Since the initial "a" property is optional, there exist two + // possible object types for "a" in the result type; one with "c" + // and one without. + expectTypeOf(result).toEqualTypeOf<{ + a: { b: string; c: number } | { b: string } + }>() + }) + + test('assign object property with optional object and optional properties', () => { + const initial = {} as { a: { b: number; c: number } } + const override = {} as { a?: { b?: string } } + const result = assign(initial, override) + + // When "exactOptionalPropertyTypes" is undefined or false in the + // tsconfig, we can't be sure if an optional property is actually + // omitted or if its value was set to undefined. For that reason, + // the resulting "a" property type must contain an `undefined` + // type. + expectTypeOf(result).toEqualTypeOf<{ + a: { b: string | number | undefined; c: number } | undefined + }>() + }) + + test('deep object property', () => { + const initial = {} as { a: { b: { c: number; d: number } } } + const override = {} as { a: { b: { c: string }; x: string } } + const result = assign(initial, override) + + expectTypeOf(result).toEqualTypeOf<{ + a: { b: { c: string; d: number }; x: string } + }>() + }) + }) + + describe('nested class instances', () => { + test('override instance with instance', () => { + const initial = {} as { a: Data; c?: number } + const override = {} as { a: Data; b: string } + // Note: "Data" should be the type, not "{ data: number }" + const result = assign(initial, override) + + expectTypeOf(result).toEqualTypeOf<{ + a: Data + b: string + c?: number + }>() + }) + + // This relies on the fact that we registered Data with the + // CustomClassRegistry type. + test('override instance with object', () => { + const initial = {} as { a: Data } + const override = {} as { a: { b: string } } + const result = assign(initial, override) + + expectTypeOf(result).toEqualTypeOf<{ + a: { b: string } + }>() + }) + + // This relies on the fact that we registered Data with the + // CustomClassRegistry type. + test('override object with instance', () => { + const initial = {} as { a: { b: string } } + const override = {} as { a: Data } + const result = assign(initial, override) + + expectTypeOf(result).toEqualTypeOf<{ + a: Data + }>() + }) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index d4cc8235e..833c6ee9c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,7 @@ "emitDeclarationOnly": true, "isolatedDeclarations": true, "target": "esnext", - "lib": ["es2020"], + "lib": ["esnext", "dom"], "esModuleInterop": true, "skipLibCheck": true, "strict": true,