From ef002faf0359b70317a3777bd838b0444d0b3ab5 Mon Sep 17 00:00:00 2001 From: AshGw Date: Sun, 11 Aug 2024 02:59:20 +0100 Subject: [PATCH] feat: add `Prune` --- src/types.ts | 230 ++++++++++++++++++++++++++++++++++++++++++++ tests/prune.test.ts | 191 ++++++++++++++++++++++++++++++++++++ 2 files changed, 421 insertions(+) create mode 100644 tests/prune.test.ts diff --git a/src/types.ts b/src/types.ts index c744b920..4c1d5a07 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2024,3 +2024,233 @@ export type DeepNotRequired = T extends UnknownFunction : { [K in Keys]+?: IfExtends, T[K]>; }; + +/** + * `NotIncluded` is a utility type that represents a value that should not be included + * in the final type. It is primarily used within the `Prune` type to exclude certain + * properties from the resulting object type. + * + * @remarks + * The `NotIncluded` type is typically used in conditional types to exclude specific + * branches of the type structure. When a condition matches, `NotIncluded` is applied + * to those properties that should be omitted from the final type structure. + * + * @example + * ```ts + * type MyType = IfEquals<'foo', 'bar', string, NotIncluded>; // Result: NotIncluded + * ``` + * + * In the context of the `Prune` type, `NotIncluded` helps in filtering out properties + * from deeply nested structures where certain conditions do not hold. + */ +export type NotIncluded = NewType<'NotIncluded', string>; + +/** + * `Prune` is a utility type that recursively removes properties of + * type `N` (defaulting to `NotIncluded`) from the object type `T`. This type is useful + * for filtering out unwanted or excluded properties in complex, deeply nested object + * structures. + * + * @remarks + * `Prune` operates by traversing the entire structure of the object type `T` and omitting + * any property that matches the type `N`. This makes it useful when you + * need to clean up or transform types where certain properties should be excluded based + * on specific conditions. + * + * @example + * Consider the following example where `Prune` is used to exclude properties marked as `NotIncluded`: + * ```ts + * type OrderData = Prune< + UserOrderDetails< + OrderStatus.DELIVERED, + UserActionType.PLACE_ORDER, + ProductType.PHYSICAL, + PaymentMethod.PAYPAL + > + >; + * ``` + * Where `UserOrderDetails` is defined as: + * ```ts + +export enum OrderStatus { + PENDING = 'PENDING', + SHIPPED = 'SHIPPED', + DELIVERED = 'DELIVERED', + CANCELLED = 'CANCELLED', + RETURNED = 'RETURNED', +} + +export enum PaymentMethod { + CREDIT_CARD = 'CREDIT_CARD', + PAYPAL = 'PAYPAL', + BANK_TRANSFER = 'BANK_TRANSFER', + CASH_ON_DELIVERY = 'CASH_ON_DELIVERY', + CREDITS = 'CREDITS', +} + +export enum UserActionType { + PLACE_ORDER = 'PLACE_ORDER', + CANCEL_ORDER = 'CANCEL_ORDER', + RETURN_ORDER = 'RETURN_ORDER', + RATE_PRODUCT = 'RATE_PRODUCT', + WRITE_REVIEW = 'WRITE_REVIEW', +} + +export enum ProductType { + DIGITAL = 'DIGITAL', + PHYSICAL = 'PHYSICAL', +} + +interface ShippingDetails { + address: string; + city: string; + postalCode: string; + country: string; + deliveryDate: Maybe; +} + +type ProductPricing = { + basePrice: number; + discount: number; + finalPrice: number; + inCountryDiscount: IfEquals; +}; + +type ProductDetails = { + type: T; + productId: string; + productName: string; + quantity: number; + pricing: ProductPricing; + physicalDescription: IfEquals< + T, + ProductType.DIGITAL, + { + color: string; + size: string; + }, + NotIncluded + >; +}; + +type PaymentBreakdown = { + baseAmount: number; + tax: number; + discount: number; + finalAmount: number; +}; + +interface PaymentDetails { + method: PM; + transactionId: Maybe; + amountPaid: number; + breakdown: IfEquals; +} + +type UserOrderDetails< + OS extends OrderStatus, + UAT extends UserActionType, + PT extends ProductType, + PM extends PaymentMethod, +> = { + orderId: string; + userId: string; + products: ProductDetails[]; + orderStatus: OS; + shipping: IfEquals< + PT, + ProductType.PHYSICAL, + { + details: IfEquals< + OS, + OrderStatus.SHIPPED | OrderStatus.DELIVERED, + ShippingDetails, + NotIncluded + >; + deliveredOn: IfEquals; + returnedOn: IfEquals; + }, + NotIncluded + >; + payment: PaymentDetails; + actions: { + type: UAT; + timestamp: number; + metadata: IfEquals< + UAT, + | UserActionType.PLACE_ORDER + | UserActionType.CANCEL_ORDER + | UserActionType.RETURN_ORDER, + { + ipAddress: string; + deviceType: string; + }, + NotIncluded + >; + }[]; +}; + * ``` + * ```ts + const testOrderData: OrderData = { + orderStatus: OrderStatus.DELIVERED, + actions: [ + { + timestamp: 1663725600000, + // Type 'UserActionType.RATE_PRODUCT' is not assignable to type 'UserActionType.PLACE_ORDER'.ts(2322) + type: UserActionType.PLACE_ORDER, + // Object literal may only specify known properties, and 'metadata' does not exist in type 'OmitExactlyByTypeDeep<{ type: UserActionType.PLACE_ORDER; timestamp: number; metadata: NotIncluded; }, NotIncluded>'.ts(2353) + // metadata: { + // ipAddress: '127.0.0.1', + // deviceType: 'desktop', + // }, + }, + ], + orderId: '123', + userId: 'abc', + products: [ + { + // Type 'ProdcutType.DIGITAL' is not assignable to type 'ProdcutType.PHYSICAL'.ts(2322) + // type: ProductType.DIGITAL, + type: ProductType.PHYSICAL, + productId: 'abc', + productName: 'Test Product', + quantity: 1, + pricing: { + basePrice: 10, + discount: 0, + finalPrice: 10, + // If the product type is digital, the 'physicalDescription' property should be omitted + // Object literal may only specify known properties, and 'inCountryDiscount' does not exist in type 'OmitExactlyByTypeDeep, NotIncluded>'.ts(2353) + // physicalDescription: { + // color: 'Red', + // size: 'Large', + // }, + inCountryDiscount: 87, + }, + }, + ], + payment: { + amountPaid: 10, + // If the PaymentMethod is CREDITS, the 'breakdown' property should be a string, no Breakdown object should exist + // Type '{ baseAmount: number; tax: number; discount: number; finalAmount: number; }' is not assignable to type 'string'. + breakdown: { + baseAmount: 10, + tax: 2, + discount: 0, + finalAmount: 12, + }, + // Type 'PaymentMethod.BANK_TRANSFER' is not assignable to type 'PaymentMethod.PAYPAL'.ts(2322) + // method: PaymentMethod.BANK_TRANSFER, + method: PaymentMethod.PAYPAL, + transactionId: '1234567890', + }, + shipping: { + deliveredOn: new Date(), + }, + }; + * ``` + * + * In this example, `Prune` removes all properties within `UserOrderDetails` + * that are marked with `NotIncluded`, resulting in a cleaned-up type structure. + */ +export type Prune = OmitExactlyByTypeDeep; diff --git a/tests/prune.test.ts b/tests/prune.test.ts new file mode 100644 index 00000000..25b96af7 --- /dev/null +++ b/tests/prune.test.ts @@ -0,0 +1,191 @@ +import { Prune, IfEquals, NotIncluded, Maybe, TestType } from 'src'; +import { test, expect } from 'vitest'; + +export enum OrderStatus { + PENDING = 'PENDING', + SHIPPED = 'SHIPPED', + DELIVERED = 'DELIVERED', + CANCELLED = 'CANCELLED', + RETURNED = 'RETURNED', +} + +export enum PaymentMethod { + CREDIT_CARD = 'CREDIT_CARD', + PAYPAL = 'PAYPAL', + BANK_TRANSFER = 'BANK_TRANSFER', + CASH_ON_DELIVERY = 'CASH_ON_DELIVERY', + CREDITS = 'CREDITS', +} + +export enum UserActionType { + PLACE_ORDER = 'PLACE_ORDER', + CANCEL_ORDER = 'CANCEL_ORDER', + RETURN_ORDER = 'RETURN_ORDER', + RATE_PRODUCT = 'RATE_PRODUCT', + WRITE_REVIEW = 'WRITE_REVIEW', +} + +export enum ProductType { + DIGITAL = 'DIGITAL', + PHYSICAL = 'PHYSICAL', +} + +interface ShippingDetails { + address: string; + city: string; + postalCode: string; + country: string; + deliveryDate: Maybe; +} + +type ProductPricing = { + basePrice: number; + discount: number; + finalPrice: number; + inCountryDiscount: IfEquals; +}; + +type ProductDetails = { + type: T; + productId: string; + productName: string; + quantity: number; + pricing: ProductPricing; + physicalDescription: IfEquals< + T, + ProductType.DIGITAL, + { + color: string; + size: string; + }, + NotIncluded + >; +}; + +type PaymentBreakdown = { + baseAmount: number; + tax: number; + discount: number; + finalAmount: number; +}; + +interface PaymentDetails { + method: PM; + transactionId: Maybe; + amountPaid: number; + breakdown: IfEquals; +} + +type UserOrderDetails< + OS extends OrderStatus, + UAT extends UserActionType, + PT extends ProductType, + PM extends PaymentMethod, +> = { + orderId: string; + userId: string; + products: ProductDetails[]; + orderStatus: OS; + shipping: IfEquals< + PT, + ProductType.PHYSICAL, + { + details: IfEquals< + OS, + OrderStatus.SHIPPED | OrderStatus.DELIVERED, + ShippingDetails, + NotIncluded + >; + deliveredOn: IfEquals; + returnedOn: IfEquals; + }, + NotIncluded + >; + payment: PaymentDetails; + actions: { + type: UAT; + timestamp: number; + metadata: IfEquals< + UAT, + | UserActionType.PLACE_ORDER + | UserActionType.CANCEL_ORDER + | UserActionType.RETURN_ORDER, + { + ipAddress: string; + deviceType: string; + }, + NotIncluded + >; + }[]; +}; + +test('_', () => { + type OrderData = Prune< + UserOrderDetails< + OrderStatus.DELIVERED, + UserActionType.PLACE_ORDER, + ProductType.PHYSICAL, + PaymentMethod.PAYPAL + > + >; + const testOrderData: OrderData = { + orderStatus: OrderStatus.DELIVERED, + actions: [ + { + timestamp: 1663725600000, + // Type 'UserActionType.RATE_PRODUCT' is not assignable to type 'UserActionType.PLACE_ORDER'.ts(2322) + type: UserActionType.PLACE_ORDER, + // Object literal may only specify known properties, and 'metadata' does not exist in type 'OmitExactlyByTypeDeep<{ type: UserActionType.PLACE_ORDER; timestamp: number; metadata: NotIncluded; }, NotIncluded>'.ts(2353) + // metadata: { + // ipAddress: '127.0.0.1', + // deviceType: 'desktop', + // }, + }, + ], + orderId: '123', + userId: 'abc', + products: [ + { + // Type 'ProdcutType.DIGITAL' is not assignable to type 'ProdcutType.PHYSICAL'.ts(2322) + // type: ProductType.DIGITAL, + type: ProductType.PHYSICAL, + productId: 'abc', + productName: 'Test Product', + quantity: 1, + pricing: { + basePrice: 10, + discount: 0, + finalPrice: 10, + // If the product type is digital, the 'physicalDescription' property should be omitted + // Object literal may only specify known properties, and 'inCountryDiscount' does not exist in type 'OmitExactlyByTypeDeep, NotIncluded>'.ts(2353) + // physicalDescription: { + // color: 'Red', + // size: 'Large', + // }, + inCountryDiscount: 87, + }, + }, + ], + payment: { + amountPaid: 10, + // If the PaymentMethod is CREDITS, the 'breakdown' property should be a string, no Breakdown object should exist + // Type '{ baseAmount: number; tax: number; discount: number; finalAmount: number; }' is not assignable to type 'string'. + breakdown: { + baseAmount: 10, + tax: 2, + discount: 0, + finalAmount: 12, + }, + // Type 'PaymentMethod.BANK_TRANSFER' is not assignable to type 'PaymentMethod.PAYPAL'.ts(2322) + // method: PaymentMethod.BANK_TRANSFER, + method: PaymentMethod.PAYPAL, + transactionId: '1234567890', + }, + shipping: { + deliveredOn: new Date(), + }, + }; + + const result: TestType = true; + expect(result).toBe(true); +});