From 5a842910be9a0686cea89541de892672a7d7ca05 Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Sat, 9 Jul 2022 02:40:27 -0700 Subject: [PATCH] custom object handling demo --- src/simple-type.ts | 4 +- src/transform/to-simple-type.ts | 70 +++++++++--- test/to-simple-type.spec.ts | 187 +++++++++++++++++++++++++++++++- 3 files changed, 242 insertions(+), 19 deletions(-) diff --git a/src/simple-type.ts b/src/simple-type.ts index deb5de6..76ce24b 100644 --- a/src/simple-type.ts +++ b/src/simple-type.ts @@ -273,7 +273,7 @@ export interface SimpleTypeMethod extends SimpleTypeBase { export interface SimpleTypeGenericArguments extends SimpleTypeBase { readonly kind: "GENERIC_ARGUMENTS"; // TODO: rename /** The generic type being instantiated */ - readonly target: Extract; + readonly target: Extract | SimpleTypeCustom; /** The arguments passed to the generic */ readonly typeArguments: SimpleType[]; /** The concrete type resulting from applying the type parameters to the generic */ @@ -324,7 +324,7 @@ export interface SimpleTypePromise extends SimpleTypeBase { export interface SimpleTypeCustom extends SimpleTypeBase { readonly kind: "CUSTOM"; - readonly data: T; + readonly extra?: T; } export type SimpleType = diff --git a/src/transform/to-simple-type.ts b/src/transform/to-simple-type.ts index 5e8a8a6..f98ef3f 100644 --- a/src/transform/to-simple-type.ts +++ b/src/transform/to-simple-type.ts @@ -66,6 +66,16 @@ interface ToSimpleTypePureOptions { cache?: WeakMap; } +interface ToCustomTypeArguments { + type: ts.Type; + checker: ts.TypeChecker; + ts: typeof tsModule; + /** True when `type` is the target of a GENERIC_ARGUMENTS instantiation */ + generic: boolean; +} + +type ToCustomType = (args: ToCustomTypeArguments) => SimpleType | ((concrete: SimpleType) => SimpleType) | undefined; + interface ToSimpleTypeConfigureTypeConstruction extends ToSimpleTypePureOptions { /** With these options, the user must provide a cache because options modify how types are built, making repeat calls with the default cache non-deterministic */ cache: WeakMap; @@ -73,6 +83,8 @@ interface ToSimpleTypeConfigureTypeConstruction extends ToSimpleTypePureOptions addMethods?: boolean; /** Add { kind: "ALIAS" } wrapper types around simple aliases. Otherwise, remove these wrappers. */ preserveSimpleAliases?: boolean; + /** If defined, called with each type, should return a CUSTOM type or undefined */ + toCustomType?: ToCustomType; } export type ToSimpleTypeOptions = ToSimpleTypePureOptions | ToSimpleTypeConfigureTypeConstruction; @@ -114,6 +126,7 @@ export function toSimpleType(type: Type | Node | SimpleType, checker?: TypeCheck cache: options.cache || DEFAULT_TYPE_CACHE, addMethods: "addMethods" in options ? options.addMethods : undefined, preserveSimpleAliases: "preserveSimpleAliases" in options ? options.preserveSimpleAliases : undefined, + toCustomType: "toCustomType" in options ? options.toCustomType : undefined, ts: getTypescriptModule() }); } @@ -214,8 +227,8 @@ function toSimpleTypeCached(type: Type, options: ToSimpleTypeInternalOptions): S * @param type * @param options */ -function liftGenericType(type: Type, options: ToSimpleTypeInternalOptions): { generic: (instantiated: SimpleType) => SimpleType; instantiated: Type } | undefined { - const enhance = (instantiated: SimpleType) => withMethods(instantiated, type, options); +function liftGenericType(type: Type, options: ToSimpleTypeInternalOptions): { wrap: (instantiated: SimpleType) => SimpleType; instantiated: Type } | undefined { + const addMethods = (instantiated: SimpleType) => withMethods(instantiated, type, options); const wrapIfAlias = (instantiated: SimpleType, ignoreTypeParams?: boolean): SimpleType => { if (isAlias(type, options.ts)) { const aliasName = type.aliasSymbol!.getName() || ""; @@ -264,19 +277,34 @@ function liftGenericType(type: Type, options: ToSimpleTypeInternalOptions): { ge return { instantiated: type, - generic: instantiated => { + wrap: instantiated => { const typeArgumentsSimpleType = typeArguments.map(t => toSimpleTypeCached(t, options)); - const generic: SimpleTypeGenericArguments = { + const customType = options.toCustomType?.({ + ...options, + type: type.target, + generic: true + }); + + const targetSimpleType = + typeof customType === "function" + ? toSimpleTypeCached(type.target, options) /// XXX unlimited recursion? + : customType; + + let generic: SimpleType = { kind: "GENERIC_ARGUMENTS", - target: toSimpleTypeCached(type.target, options) as any, + target: targetSimpleType as any, instantiated, typeArguments: typeArgumentsSimpleType }; + if (typeof customType === "function") { + generic = customType(generic); + } + // This makes current tests work, but may be actually incorrect. // vvvvvv - return enhance(wrapIfAlias(generic, true)); + return addMethods(wrapIfAlias(generic, true)); } }; } @@ -286,8 +314,8 @@ function liftGenericType(type: Type, options: ToSimpleTypeInternalOptions): { ge return { // TODO: better type safety instantiated: (type as any).target || type, - generic: instantiated => { - return enhance(wrapIfAlias(instantiated)); + wrap: instantiated => { + return addMethods(wrapIfAlias(instantiated)); } }; } @@ -330,20 +358,37 @@ function memberWithMethods(obj: T, symbol: ts.Symbol }; } -function toSimpleTypeInternal(type: Type, options: ToSimpleTypeInternalOptions): SimpleType { +function toSimpleTypeInternal(outerType: Type, options: ToSimpleTypeInternalOptions): SimpleType { const { checker, ts } = options; + let type = outerType; const symbol: ESSymbol | undefined = type.getSymbol(); const name = symbol != null ? getRealSymbolName(symbol, ts) : undefined; let simpleType: SimpleType | undefined; + let enhance: (instantiated: SimpleType) => SimpleType = t => withMethods(t, type, options); const generic = liftGenericType(type, options); if (generic != null) { type = generic.instantiated; + const originalEnhance = enhance; + enhance = t => generic.wrap(originalEnhance(t)); } - const enhance = (obj: SimpleType) => withMethods(obj, type, options); + // Custom types + const customType = options.toCustomType?.({ + ...options, + type, + generic: false + }); + if (customType) { + if (typeof customType === "function") { + const originalEnhance = enhance; + enhance = t => withMethods(customType(originalEnhance(t)), outerType, options); + } else { + return enhance(customType); + } + } // Literal types if (isLiteral(type, ts)) { @@ -678,11 +723,6 @@ function toSimpleTypeInternal(type: Type, options: ToSimpleTypeInternalOptions): }; } - // Lift generic types and aliases if possible - if (generic != null) { - return generic.generic(enhance(simpleType)); - } - return enhance(simpleType); } diff --git a/test/to-simple-type.spec.ts b/test/to-simple-type.spec.ts index cd7b4f0..fbb5b5f 100644 --- a/test/to-simple-type.spec.ts +++ b/test/to-simple-type.spec.ts @@ -5,10 +5,13 @@ import { SimpleType, SimpleTypeAlias, SimpleTypeArray, + SimpleTypeString, + SimpleTypeCustom, SimpleTypeGenericArguments, SimpleTypeInterface, + SimpleTypeMember, + SimpleTypeMemberNamed, SimpleTypeObject, - SimpleTypeString, SimpleTypeUnion, toSimpleType } from "../src"; @@ -48,7 +51,8 @@ export type ContentPointer = RecordPointer<'block' | 'collection'> test("it adds methods when addMethods is set", ctx => { const { types, typeChecker } = getTestTypes(["SimpleAlias", "SimpleAliasExample", "GenericInterface", "GenericInterfaceExample"], TEST_TYPES); const simpleType = toSimpleType(types.SimpleAliasExample, typeChecker, { - addMethods: true + addMethods: true, + cache: new WeakMap() }); const toTs = simpleType.getTypescript?.(); @@ -371,6 +375,185 @@ test("generic type alias handling", ctx => { ctx.deepEqual(recordPointerExpected, toSimpleType(types.RecordPointer, typeChecker)); }); +const CUSTOM_TYPE_EXAMPLE = ` +const sym = Symbol('fool') + +/// XXX: We still can't "see" GENERIC_ARGUMENTS of many aliases :( +export type GenericAlias = T & CustomType +export type RecordId = GenericAlias +export interface CustomType { + [typeof sym]: T +} +export type ExampleType = { id: string, related_id: CustomType } +export type ExampleType2 = { id: string, related_id: RecordId<'block'> } +`; + +test("custom type handling, non-generic", ctx => { + const { types, typeChecker } = getTestTypes(["CustomType", "ExampleType"], CUSTOM_TYPE_EXAMPLE); + const expected: SimpleTypeObject = { + kind: "OBJECT", + name: "ExampleType", + members: [ + { name: "id", type: { kind: "STRING" } }, + { + name: "related_id", + type: { + kind: "GENERIC_ARGUMENTS", + target: { kind: "CUSTOM", name: "Very cool custom target" }, + instantiated: { kind: "OBJECT", name: "CustomType", members: [] }, + typeArguments: [{ kind: "STRING" }] + } + } + ] + }; + + const actual = toSimpleType(types.ExampleType, typeChecker, { + cache: new WeakMap(), + toCustomType({ type }) { + if (type === types.CustomType) { + return { + kind: "CUSTOM", + name: "Very cool custom target" + }; + } + } + }); + + ctx.deepEqual(actual, expected); +}); + +test("custom type handling, generic", ctx => { + const { types, typeChecker } = getTestTypes(["CustomType", "ExampleType"], CUSTOM_TYPE_EXAMPLE); + const expected: SimpleTypeObject = { + kind: "OBJECT", + name: "ExampleType", + members: [ + { name: "id", type: { kind: "STRING" } }, + { + name: "related_id", + type: { + kind: "CUSTOM", + name: "Generic custom type", + extra: { + extractedParameter: { kind: "STRING" }, + instantiated: { kind: "OBJECT", name: "CustomType", members: [] } + } + } + } + ] + }; + + const actual = toSimpleType(types.ExampleType, typeChecker, { + cache: new WeakMap(), + toCustomType({ type, generic }) { + if (generic && type === types.CustomType) { + return function wrap(simpleType) { + if (simpleType.kind !== "GENERIC_ARGUMENTS") { + ctx.is(simpleType.kind, "GENERIC_ARGUMENTS", "should be a GENERIC_ARGUMENTS"); + throw "no"; + } + return { + kind: "CUSTOM", + name: "Generic custom type", + extra: { + extractedParameter: simpleType.typeArguments[0], + instantiated: simpleType.instantiated + } + }; + }; + } + } + }); + + ctx.deepEqual(actual, expected); +}); + +test("custom type handling, wrapper with generic anchor", ctx => { + const { types, typeChecker } = getTestTypes(["CustomType", "ExampleType2"], CUSTOM_TYPE_EXAMPLE); + const expected: SimpleTypeObject = { + kind: "OBJECT", + name: "ExampleType2", + members: [ + { name: "id", type: { kind: "STRING" } }, + { + name: "related_id", + type: { kind: "CUSTOM", name: "RecordId", extra: { table: "block" } } + } + ] + }; + + const actual = toSimpleType(types.ExampleType2, typeChecker, { + cache: new WeakMap(), + toCustomType({ type, generic }) { + // Anchor - it's easy to find a generic application of an interface, + // so we search for that happening and turn it into a custom MetaData type + // by extracting the generic argument. + if (generic && type === types.CustomType) { + return function wrapGeneric(simpleType) { + if (simpleType.kind !== "GENERIC_ARGUMENTS") { + ctx.is(simpleType.kind, "GENERIC_ARGUMENTS", "should be a GENERIC_ARGUMENTS"); + throw "no"; + } + + return { + kind: "CUSTOM", + name: "MetaData", + extra: simpleTypeToLiteral(simpleType.typeArguments[0]) + }; + }; + } else { + return simpleType => { + // Abstraction - check every type converted to SimpleType for special patterns. + // In this case, we look for RecordId alias, which contains a MetaData anchor. + // Then, we replace the whole abstraction type with just its metadata. + if (simpleType.kind === "ALIAS" && simpleType.name === "RecordId" && simpleType.target.kind === "INTERSECTION") { + const metaDataType = simpleType.target.types.find(t => t.kind === "CUSTOM" && t.name === "MetaData") as SimpleTypeCustom; + if (metaDataType) { + return { + kind: "CUSTOM", + name: "RecordId", + extra: metaDataType.extra + }; + } + } + return simpleType; + }; + } + } + }); + + ctx.deepEqual(actual, expected); +}); + +function simpleTypeToLiteral(simpleType: SimpleType): unknown { + if ("value" in simpleType) { + return simpleType.value; + } + + if ("members" in simpleType && simpleType.members) { + const result: any = simpleType.kind === "TUPLE" ? [] : {}; + simpleType.members.forEach((member: SimpleTypeMember | SimpleTypeMemberNamed, i) => { + const name = "name" in member ? member.name : i; + result[name] = simpleTypeToLiteral(member.type); + }); + return result; + } + + if (simpleType.kind === "UNION") { + return simpleType.types.map(simpleTypeToLiteral); + } + + if (simpleType.kind === "NULL") { + return null; + } + + if (simpleType.kind === "UNDEFINED") { + return undefined; + } + + throw new Error(`Cannot convert SimpleType to literal: ${JSON.stringify(simpleType, null, 2)}`); +} + const stringSimpleType: SimpleTypeString = { kind: "STRING" };