From e3900f7a2663212913f81de912138eb37b50c168 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli Date: Sun, 28 Aug 2022 19:31:57 -0400 Subject: [PATCH] fix(Resource): class-based resource had no inference on the thunk previously, when typing out the return object of the thunk when using a class-based resource, the whole object would be "unknown", which means that you could provide _anything_ there, and it wouldn't matter to the type checker. Now, you'll get intellisense in the thunk as well as errors when your thunk is incorrect. This may cause errors during the upgrade to this version, but it should be a "correct change" to resolve. --- ember-resources/package.json | 1 + .../src/core/-type-tests/args-helpers.test.ts | 79 +++++++++ .../core/-type-tests/thunk-helpers.test.ts | 73 +++++++++ .../src/core/-type-tests/types.test.ts | 13 ++ .../-type-tests/from-thunk-inference.test.ts | 127 +++++++++++++++ .../class-based/-type-tests/resource.test.ts | 104 ++++++++++++ .../src/core/class-based/resource.ts | 124 ++++++++------- ember-resources/src/core/class-based/types.ts | 54 ------- ember-resources/src/core/types.ts | 72 +-------- ember-resources/src/core/types/base.ts | 50 ++++++ .../src/core/types/signature-args.ts | 29 ++++ ember-resources/src/core/types/thunk.ts | 150 ++++++++++++++++++ ember-resources/src/index.ts | 3 +- ember-resources/src/util/map.ts | 27 ++-- pnpm-lock.yaml | 2 + .../app/components/glint-colocated.ts | 2 +- .../ember-app/app/components/glint-gts.gts | 2 +- .../components/glint-setComponentTemplate.ts | 2 +- testing/ember-app/tests/core/js-test.ts | 4 +- .../ember-app/tests/type-tests/class-based.ts | 69 +++++--- 20 files changed, 765 insertions(+), 222 deletions(-) create mode 100644 ember-resources/src/core/-type-tests/args-helpers.test.ts create mode 100644 ember-resources/src/core/-type-tests/thunk-helpers.test.ts create mode 100644 ember-resources/src/core/-type-tests/types.test.ts create mode 100644 ember-resources/src/core/class-based/-type-tests/from-thunk-inference.test.ts create mode 100644 ember-resources/src/core/class-based/-type-tests/resource.test.ts delete mode 100644 ember-resources/src/core/class-based/types.ts create mode 100644 ember-resources/src/core/types/base.ts create mode 100644 ember-resources/src/core/types/signature-args.ts create mode 100644 ember-resources/src/core/types/thunk.ts diff --git a/ember-resources/package.json b/ember-resources/package.json index c3bc14b29..461767884 100644 --- a/ember-resources/package.json +++ b/ember-resources/package.json @@ -143,6 +143,7 @@ "ember-async-data": "^0.6.0", "ember-template-lint": "3.16.0", "eslint": "^7.32.0", + "expect-type": "^0.13.0", "npm-run-all": "4.1.5", "rollup": "2.78.1", "rollup-plugin-terser": "^7.0.2", diff --git a/ember-resources/src/core/-type-tests/args-helpers.test.ts b/ember-resources/src/core/-type-tests/args-helpers.test.ts new file mode 100644 index 000000000..8bedcda7f --- /dev/null +++ b/ember-resources/src/core/-type-tests/args-helpers.test.ts @@ -0,0 +1,79 @@ +import { expectTypeOf } from 'expect-type'; + +import { Resource } from '../class-based'; + +import type { ArgsFrom } from '../class-based/resource'; +import type { EmptyObject, Named, Positional } from '[core-types]'; + +// ----------------------------------------------------------- +// ----------------------------------------------------------- +// ----------------------------------------------------------- + +/** + * ----------------------------------------------------------- + * Named + * ----------------------------------------------------------- + */ +expectTypeOf>().toEqualTypeOf(); +expectTypeOf>().toEqualTypeOf<{ foo: number }>(); +expectTypeOf>().toEqualTypeOf<{ foo: number }>(); +expectTypeOf>().toEqualTypeOf(); +expectTypeOf>().toEqualTypeOf(); +expectTypeOf>().toEqualTypeOf<{ foo: number }>(); +// @ts-expect-error +expectTypeOf>().toEqualTypeOf<{ foo: number }>(); + +/** + * ----------------------------------------------------------- + * Positional + * ----------------------------------------------------------- + */ +expectTypeOf>().toEqualTypeOf<[]>(); +expectTypeOf>().toEqualTypeOf<[number]>(); +expectTypeOf>().toEqualTypeOf<[number]>(); +expectTypeOf>().toEqualTypeOf<[]>(); +expectTypeOf>().toEqualTypeOf<[]>(); +expectTypeOf>().toEqualTypeOf< + [number] +>(); +// @ts-expect-error +expectTypeOf>().toEqualTypeOf< + [number] +>(); + +/** + * ----------------------------------------------------------- + * ArgsFrom + * ----------------------------------------------------------- + */ +class Foo { + foo = 'foo'; +} +class Bar extends Resource { + bar = 'bar'; +} +class Baz extends Resource<{ Named: { baz: string } }> { + baz = 'baz'; +} +class Bax extends Resource<{ Positional: [string] }> { + bax = 'bax'; +} +// {} does not extend Resource +// @ts-expect-error +expectTypeOf>().toEqualTypeOf(); +// unknown does not extend Resource +// @ts-expect-error +expectTypeOf>().toEqualTypeOf(); +// number does not extend Resource +// @ts-expect-error +expectTypeOf>().toEqualTypeOf(); +// string does not extend Resource +// @ts-expect-error +expectTypeOf>().toEqualTypeOf(); +// Foo does not extend Resource +// @ts-expect-error +expectTypeOf>().toEqualTypeOf(); + +expectTypeOf>().toEqualTypeOf(); +expectTypeOf>().toEqualTypeOf<{ Named: { baz: string } }>(); +expectTypeOf>().toEqualTypeOf<{ Positional: [string] }>(); diff --git a/ember-resources/src/core/-type-tests/thunk-helpers.test.ts b/ember-resources/src/core/-type-tests/thunk-helpers.test.ts new file mode 100644 index 000000000..41c61cf06 --- /dev/null +++ b/ember-resources/src/core/-type-tests/thunk-helpers.test.ts @@ -0,0 +1,73 @@ +/** + * These type tests are sorted alphabetically by the name of the type utility + */ +import { expectTypeOf } from 'expect-type'; + +import type { AsThunk, EmptyObject, LoosenThunkReturn, NoArgs, ThunkReturnFor } from '[core-types]'; + +/** + * ----------------------------------------------------------- + * AsThunk - uses ThunkReturnFor + LoosenThunkReturn + * ----------------------------------------------------------- + */ +expectTypeOf>().toEqualTypeOf<() => NoArgs | [] | EmptyObject | undefined | void>(); +expectTypeOf>().toEqualTypeOf< + () => NoArgs | [] | EmptyObject | undefined | void +>(); +expectTypeOf>().toEqualTypeOf<() => NoArgs | [] | EmptyObject | undefined | void>(); +expectTypeOf>().toEqualTypeOf< + () => NoArgs | [] | EmptyObject | undefined | void +>(); + +expectTypeOf>().toEqualTypeOf< + () => { named: { foo: number } } | { foo: number } +>(); +expectTypeOf>().toEqualTypeOf< + () => { named: { foo: number }; positional: [string] } +>(); +expectTypeOf>().toEqualTypeOf< + () => { positional: [number] } | [number] +>(); + +/** + * ----------------------------------------------------------- + * LoosenThunkReturn + * ----------------------------------------------------------- + */ +expectTypeOf>().toEqualTypeOf< + { foo: 1 } | { named: { foo: 1 } } +>(); +expectTypeOf>().toEqualTypeOf< + [string] | { positional: [string] } +>(); +expectTypeOf>().toEqualTypeOf<{ + named: { foo: 1 }; + positional: [string]; +}>(); + +/** + * ----------------------------------------------------------- + * ThunkReturnFor + * ----------------------------------------------------------- + */ +expectTypeOf>().toEqualTypeOf(); +expectTypeOf>().toEqualTypeOf(); +expectTypeOf>().toEqualTypeOf(); +// How to guard against this situation? +// expectTypeOf>>().toEqualTypeOf(); +expectTypeOf>().toEqualTypeOf<{ + positional: [string]; + named: EmptyObject; +}>(); +expectTypeOf>().toEqualTypeOf<{ + positional: [string]; + named: EmptyObject; +}>(); +expectTypeOf>().toEqualTypeOf<{ + positional: []; + named: { baz: string }; +}>(); +expectTypeOf>().toEqualTypeOf<{ + positional: []; + named: { baz: string }; +}>(); diff --git a/ember-resources/src/core/-type-tests/types.test.ts b/ember-resources/src/core/-type-tests/types.test.ts new file mode 100644 index 000000000..5c3844754 --- /dev/null +++ b/ember-resources/src/core/-type-tests/types.test.ts @@ -0,0 +1,13 @@ +import { expectTypeOf } from 'expect-type'; + +import type { Class, Constructor } from '[core-types]'; + +class A { + a = 1; +} + +/** + * Class + Constructor + */ +expectTypeOf>>().toMatchTypeOf(); +expectTypeOf>>().toMatchTypeOf(); diff --git a/ember-resources/src/core/class-based/-type-tests/from-thunk-inference.test.ts b/ember-resources/src/core/class-based/-type-tests/from-thunk-inference.test.ts new file mode 100644 index 000000000..e8b08776e --- /dev/null +++ b/ember-resources/src/core/class-based/-type-tests/from-thunk-inference.test.ts @@ -0,0 +1,127 @@ +/** + * + * NOTE: these examples are explicitly for testing types and may not be + * suitable for actual runtime usage + */ +import { expectTypeOf } from 'expect-type'; + +// import { AsThunk, Class, ContextOf, ExpandThunkReturn, Named, Positional } from '[core-types]'; +import { Resource } from '../resource'; + +/** + * with no arguments specified + */ +class A extends Resource { + a = 1; +} + +// TODO: rename to create when there are no args? +// are there use cases for class-based Resources without args? +// no args seems like it'd be easier as a function-resource. +// Valid, no args present +// A.from(); +A.from(() => ({})); +A.from(() => []); + +// Invalid, A does not expect args +// @ts-expect-error +A.from(() => ({ positional: [1] })); +// +// Invalid, A does not expect args +// @ts-expect-error +A.from(() => ({ named: { foo: 2 } })); + +// valid, empty args are ok +A.from(() => ({ positional: [], named: {} })); + +export class UsageA { + a = A.from(this, () => ({})); + a1 = A.from(this, () => []); + + // Invalid, A does not expect args + // @ts-expect-error + a2 = A.from(this, () => ({ positional: [1] })); + + // Invalid, A does not expect args + // @ts-expect-error + a3 = A.from(this, () => ({ named: { foo: 2 } })); + + // valid, empty args are ok + a4 = A.from(this, () => ({ positional: [], named: {} })); +} + +/** + * with all arguments specified + */ +type BArgs = { + positional: [num: number, greeting: string]; + named: { + num: number; + str: string; + }; +}; + +export class B extends Resource { + b = 'b'; +} + +// Valid, all arguments provided +B.from(() => { + return { + positional: [1, 'hi'], + named: { num: 2, str: 'there' }, + }; +}); + +export class UsageB { + // everything missing + // @ts-expect-error + b = B.from(this, () => ({})); + + // named is missing + // @ts-expect-error + b1 = B.from(this, () => ({ positional: [1, 'hi'] })); + + // positional is missing + // @ts-expect-error + b2 = B.from(this, () => ({ named: { num: 2, str: 'there' } })); + + // positional is incorrect + // @ts-expect-error + b3 = B.from(this, () => ({ positional: ['hi'] })); + + // named is incorrect + // @ts-expect-error + b4 = B.from(this, () => ({ named: { str: 'there' } })); + + // valid -- all args present + b5 = B.from(this, () => ({ positional: [1, 'hi'], named: { num: 2, str: 'there' } })); +} + +/** + * with all arguments, but capitalized (Signature style) + */ + +type CArgs = { + Positional: [number, string]; + Named: { + num: number; + str: string; + }; +}; + +export class C extends Resource { + c = 'c'; +} + +/** + * The return value of the thunk has the correct type + */ +export class UsageC { + // decorator not needed for the type test (I don't want to import it) + /* @use */ cUse = C.from(() => ({ positional: [1, 'two'], named: { num: 3, str: 'four' } })); + cThis = C.from(this, () => ({ positional: [1, 'two'], named: { num: 3, str: 'four' } })); +} + +expectTypeOf(new UsageC().cUse).toEqualTypeOf(); +expectTypeOf(new UsageC().cThis).toEqualTypeOf(); diff --git a/ember-resources/src/core/class-based/-type-tests/resource.test.ts b/ember-resources/src/core/class-based/-type-tests/resource.test.ts new file mode 100644 index 000000000..c8b546e4e --- /dev/null +++ b/ember-resources/src/core/class-based/-type-tests/resource.test.ts @@ -0,0 +1,104 @@ +import { expectTypeOf } from 'expect-type'; + +import { Resource } from '../resource'; + +import type { ArgsFrom } from '../resource'; +import type { Named, Positional } from '[core-types]'; + +/** + * Base class + */ +expectTypeOf().parameters.toEqualTypeOf< + [Positional, Named] +>(); + +/** + * Helpers + */ +interface SimpleArgs { + Positional: [number, string]; +} +class SomeResource extends Resource {} +class SomeOtherResource extends Resource {} +class SomeClass { + foo = 'hello'; +} + +expectTypeOf>>().toEqualTypeOf(); + +expectTypeOf>().toEqualTypeOf(); + +expectTypeOf>>().toEqualTypeOf(); + +// SomeClass is not a sub-class of Resource +// @ts-expect-error +expectTypeOf>().toEqualTypeOf(); + +// unknown is not a sub-class of Resource +// @ts-expect-error +expectTypeOf>().toEqualTypeOf(); + +/** + * with no arguments specified + */ +class A extends Resource { + a = 1; +} + +// @ts-expect-error +A.from({}); + +/** + * with all arguments specified + */ +type BArgs = { + positional: [number, string]; + named: { + num: number; + str: string; + }; +}; + +export class B extends Resource { + b = 'b'; +} + +expectTypeOf().parameters.toEqualTypeOf<[Positional, Named]>(); +expectTypeOf>().toEqualTypeOf(); + +/** + * with all arguments, but capitalized (Signature style) + */ + +type CArgs = { + Positional: [number, string]; + Named: { + /** + * How do I test / assert JSDoc is carried? + * (it is, but I can't prove it) + * docs? + */ + num: number; + str: string; + }; +}; + +export class C extends Resource { + c = 'c'; +} + +expectTypeOf().parameters.toEqualTypeOf<[Positional, Named]>(); +expectTypeOf>().toEqualTypeOf(); + +C.from(() => ({ positional: [1, 'hi'], named: { num: 2, str: 'hi' } })); +C.from({}, () => ({ positional: [1, 'hi'], named: { num: 2, str: 'hi' } })); + +/** + * With only positional args + */ +export class Doubler extends Resource<{ positional: [number] }> { + doubled = 2; +} + +Doubler.from(() => [1]); +Doubler.from({}, () => [2]); diff --git a/ember-resources/src/core/class-based/resource.ts b/ember-resources/src/core/class-based/resource.ts index b4667a217..249f9975b 100644 --- a/ember-resources/src/core/class-based/resource.ts +++ b/ember-resources/src/core/class-based/resource.ts @@ -9,8 +9,7 @@ import { INTERNAL } from 'core/function-based/types'; import { DEFAULT_THUNK, normalizeThunk } from '../utils'; -import type { Cache, Thunk } from '../types'; -import type { Named, Positional } from './types'; +import type { AsThunk, Cache, Constructor, Named, Positional, Thunk } from '[core-types]'; import type Owner from '@ember/owner'; import type { HelperLike } from '@glint/template'; // this lint thinks this type import is used by decorator metadata... @@ -18,6 +17,15 @@ import type { HelperLike } from '@glint/template'; // eslint-disable-next-line @typescript-eslint/consistent-type-imports import type { Invoke } from '@glint/template/-private/integration'; +/** + * @private utility type + * + * Returns the Thunk-cased args for a given Class/InstanceType of Resource + */ +export type ArgsFrom> = Klass extends Resource + ? Args + : never; + /** * https://gist.github.com/dfreeman/e4728f2f48737b44efb99fa45e2d22ef#typing-the-return-value-implicitly * @@ -33,6 +41,8 @@ type ResourceHelperLike = InstanceType< }> >; +declare const __ResourceArgs__: unique symbol; + /** * The 'Resource' base class has only one lifecycle hook, `modify`, which is called during * instantiation of the resource as well as on every update of any of any consumed args. @@ -109,7 +119,18 @@ type ResourceHelperLike = InstanceType< * This way, consumers only need one import. * */ -export class Resource { +export class Resource { + /** + * @private (secret) + * + * Because classes are kind of like interfaces, + * we need "something" to help TS know what a Resource is. + * + * This isn't a real API, but does help with type inference + * with the ArgsFrom utility above + */ + declare [__ResourceArgs__]: Args; + /** * @private (secret) * @@ -124,7 +145,28 @@ export class Resource { * * Without this, the static method, from, would have a type error. */ - declare [Invoke]: ResourceHelperLike[typeof Invoke]; + declare [Invoke]: ResourceHelperLike[typeof Invoke]; + + /** + * For use in the body of a class. + * + * `from` is what allows resources to be used in JS, they hide the reactivity APIs + * from the consumer so that the surface API is smaller. + * + * ```js + * import { Resource, use } from 'ember-resources'; + * + * class SomeResource extends Resource {} + * + * class MyClass { + * @use data = SomeResource.from(() => [ ... ]); + * } + * ``` + */ + static from>( + this: Constructor, + thunk: AsThunk> + ): SomeResource; /** * For use in the body of a class. @@ -169,38 +211,17 @@ export class Resource { * } * ``` */ - static from any>( - this: T, - context: InstanceType any>, - thunk?: Thunk | (() => unknown) - ): InstanceType; - - /** - * For use in the body of a class. - * - * `from` is what allows resources to be used in JS, they hide the reactivity APIs - * from the consumer so that the surface API is smaller. - * - * ```js - * import { Resource, use } from 'ember-resources'; - * - * class SomeResource extends Resource {} - * - * class MyClass { - * @use data = SomeResource.from(() => [ ... ]); - * } - * ``` - */ - static from any>( - this: T, - thunk: Thunk | (() => unknown) - ): InstanceType; + static from>( + this: Constructor, + context: unknown, + thunk: AsThunk> + ): SomeResource; - static from any>( - this: T, - contextOrThunk: InstanceType any> | Thunk | (() => unknown), - thunkOrUndefined?: undefined | Thunk | (() => unknown) - ): InstanceType { + static from>( + this: Constructor, + contextOrThunk: unknown | AsThunk>, + thunkOrUndefined?: undefined | AsThunk> + ): SomeResource { /** * This first branch is for * @@ -218,14 +239,6 @@ export class Resource { * */ if (typeof contextOrThunk === 'function') { - /** - * This cast is a little weird, because the narrowing from the - * typeof check, while removing `object` from `contextOrThunk` does - * add in `Function` to the type union and I don't know of a better way - * to manage the type narrowing here. - */ - let thunk = contextOrThunk as Thunk | (() => unknown); - /** * We have to lie here because TypeScript doesn't allow decorators * to alter the type of a property. @@ -234,11 +247,11 @@ export class Resource { * but is not supported for use by any other conusmer. */ return { - thunk, + thunk: contextOrThunk, definition: this, type: 'class-based', [INTERNAL]: true, - } as unknown as InstanceType; + } as unknown as SomeResource; } /** @@ -258,7 +271,6 @@ export class Resource { // owner must be | unknown as to not // break existing code - constructor(owner: Owner | unknown) { setOwner(this, owner as Owner); } @@ -267,21 +279,21 @@ export class Resource { * this lifecycle hook is called whenever arguments to the resource change. * This can be useful for calling functions, comparing previous values, etc. */ - modify?(positional?: Positional, named?: Named): void; + modify?(positional: Positional, named: Named): void; } -function resourceOf any, Args extends unknown[] = unknown[]>( - context: object, - klass: new (...args: any) => InstanceType, - thunk?: Thunk | (() => Args) -): Instance { +function resourceOf>( + context: unknown, + klass: Constructor, + thunk?: Thunk +): SomeResource { assert( `Expected second argument, klass, to be a Resource. ` + `Instead, received some ${typeof klass}, ${klass.name}`, klass.prototype instanceof Resource ); - let cache: Cache; + let cache: Cache; /* * Having an object that we use invokeHelper + getValue on @@ -290,12 +302,12 @@ function resourceOf any, Args extends unk * */ let target = { - get value(): Instance { + get value(): SomeResource { if (!cache) { cache = invokeHelper(context, klass, () => normalizeThunk(thunk || DEFAULT_THUNK)); } - return getValue(cache); + return getValue(cache); }, }; @@ -325,5 +337,5 @@ function resourceOf any, Args extends unk return Reflect.getOwnPropertyDescriptor(instance, key); }, - }) as never as Instance; + }) as never as SomeResource; } diff --git a/ember-resources/src/core/class-based/types.ts b/ember-resources/src/core/class-based/types.ts deleted file mode 100644 index 5603bda99..000000000 --- a/ember-resources/src/core/class-based/types.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * All of this is baseds off the types for @glimmer/component - * (post starting official typescript support in ember) - */ - -// Type-only "symbol" to use with `EmptyObject` below, so that it is *not* -// equivalent to an empty interface. -declare const Empty: unique symbol; - -/** - * This provides us a way to have a "fallback" which represents an empty object, - * without the downsides of how TS treats `{}`. Specifically: this will - * correctly leverage "excess property checking" so that, given a component - * which has no named args, if someone invokes it with any named args, they will - * get a type error. - * - * @internal This is exported so declaration emit works (if it were not emitted, - * declarations which fall back to it would not work). It is *not* intended for - * public usage, and the specific mechanics it uses may change at any time. - * The location of this export *is* part of the public API, because moving it - * will break existing declarations, but is not legal for end users to import - * themselves, so ***DO NOT RELY ON IT***. - */ -export type EmptyObject = { [Empty]?: true }; - -type GetOrElse = K extends keyof Obj ? Obj[K] : Fallback; - -type ArgsFor = - // Signature['Args'] - S extends { Named?: object; Positional?: unknown[] } - ? { - Named: GetOrElse; - Positional: GetOrElse; - } - : S extends { named?: object; positional?: unknown[] } - ? { - Named: GetOrElse; - Positional: GetOrElse; - } - : { Named: EmptyObject; Positional: [] }; - -/** - * Converts a variety of types to the expanded arguments type - * that aligns with the 'Args' portion of the 'Signature' types - * from ember's helpers, modifiers, components, etc - */ -export type ExpandArgs = T extends any[] - ? ArgsFor<{ Positional: T }> - : T extends any - ? ArgsFor - : never; - -export type Positional = ExpandArgs['Positional']; -export type Named = ExpandArgs['Named']; diff --git a/ember-resources/src/core/types.ts b/ember-resources/src/core/types.ts index 7630cf0a0..608680b48 100644 --- a/ember-resources/src/core/types.ts +++ b/ember-resources/src/core/types.ts @@ -1,14 +1,6 @@ -export type Fn = (...args: any[]) => any; - -/** - * This is a utility interface that represents the resulting args structure after - * the thunk is normalized. - * - */ -export interface ArgsWrapper { - positional?: unknown[]; - named?: Record; -} +export * from './types/base'; +export * from './types/signature-args'; +export * from './types/thunk'; // typed-ember should provide this from // @glimmer/tracking/primitives/cache @@ -23,61 +15,3 @@ export interface Cache { export interface Helper { /* no clue what's in here */ } - -/** - * With the exception of the `useResource` + `class` combination, all Thunks are optional. - * The main caveat is that if your resources will not update without a thunk -- or consuming - * tracked data within setup / initialization (which is done for you with `useFunction`). - * - * - The thunk is "just a function" that allows tracked data to be lazily consumed by the resource. - * - * Note that thunks are awkward when they aren't required -- they may even be awkward - * when they are required. Whenever possible, we should rely on auto-tracking, such as - * what [[trackedFunction]] provides. - * - * So when and why are thunks needed? - * - when we want to manage reactivity *separately* from a calling context. - * - in many cases, the thunk is invoked during setup and update of various Resources, - * so that the setup and update evaluations can "entangle" with any tracked properties - * accessed within the thunk. This allows changes to those tracked properties to - * cause the Resources to (re)update. - * - * The args thunk accepts the following data shapes: - * ``` - * () => [an, array] - * () => ({ hello: 'there' }) - * () => ({ named: {...}, positional: [...] }) - * ``` - * - * #### An array - * - * when an array is passed, inside the Resource, `this.args.named` will be empty - * and `this.args.positional` will contain the result of the thunk. - * - * _for function resources, this is the only type of thunk allowed._ - * - * #### An object of named args - * - * when an object is passed where the key `named` is not present, - * `this.args.named` will contain the result of the thunk and `this.args.positional` - * will be empty. - * - * #### An object containing both named args and positional args - * - * when an object is passed containing either keys: `named` or `positional`: - * - `this.args.named` will be the value of the result of the thunk's `named` property - * - `this.args.positional` will be the value of the result of the thunk's `positional` property - * - * This is the same shape of args used throughout Ember's Helpers, Modifiers, etc - * - */ -export type Thunk = - // No Args - | (() => []) - | (() => void) - // plain array / positional args - | (() => Required['positional']) - // plain named args - | (() => Required['named']) - // both named and positional args... but why would you choose this? :upsidedownface: - | (() => ArgsWrapper); diff --git a/ember-resources/src/core/types/base.ts b/ember-resources/src/core/types/base.ts new file mode 100644 index 000000000..323786d59 --- /dev/null +++ b/ember-resources/src/core/types/base.ts @@ -0,0 +1,50 @@ +/** + * NOTE: + * Empty, EmptyObject, and GetOrElse are copied from @glimmer/component + */ + +export type Fn = (...args: any[]) => any; + +export type Constructor = abstract new (...args: any) => Instance; +export interface Class { + new (...args: unknown[]): Instance; +} + +/** + * @private utility type + */ +export type NoArgs = { + named: EmptyObject; + positional: []; +}; + +/** + * This is a utility interface that represents the resulting args structure after + * the thunk is normalized. + */ +export interface ArgsWrapper { + positional?: unknown[]; + named?: Record; +} + +// Type-only "symbol" to use with `EmptyObject` below, so that it is *not* +// equivalent to an empty interface. +declare const Empty: unique symbol; + +/** + * This provides us a way to have a "fallback" which represents an empty object, + * without the downsides of how TS treats `{}`. Specifically: this will + * correctly leverage "excess property checking" so that, given a component + * which has no named args, if someone invokes it with any named args, they will + * get a type error. + * + * @internal This is exported so declaration emit works (if it were not emitted, + * declarations which fall back to it would not work). It is *not* intended for + * public usage, and the specific mechanics it uses may change at any time. + * The location of this export *is* part of the public API, because moving it + * will break existing declarations, but is not legal for end users to import + * themselves, so ***DO NOT RELY ON IT***. + */ +export type EmptyObject = { [Empty]?: true }; + +export type GetOrElse = K extends keyof Obj ? Obj[K] : Fallback; diff --git a/ember-resources/src/core/types/signature-args.ts b/ember-resources/src/core/types/signature-args.ts new file mode 100644 index 000000000..eb368e91c --- /dev/null +++ b/ember-resources/src/core/types/signature-args.ts @@ -0,0 +1,29 @@ +import type { EmptyObject, GetOrElse } from './base'; + +export type ArgsFor = + // Signature['Args'] + S extends { Named?: object; Positional?: unknown[] } + ? { + Named: GetOrElse; + Positional: GetOrElse; + } + : S extends { named?: object; positional?: unknown[] } + ? { + Named: GetOrElse; + Positional: GetOrElse; + } + : { Named: EmptyObject; Positional: [] }; + +/** + * Converts a variety of types to the expanded arguments type + * that aligns with the 'Args' portion of the 'Signature' types + * from ember's helpers, modifiers, components, etc + */ +export type ExpandArgs = T extends any[] + ? ArgsFor<{ Positional: T }> + : T extends any + ? ArgsFor + : never; + +export type Positional = ExpandArgs['Positional']; +export type Named = ExpandArgs['Named']; diff --git a/ember-resources/src/core/types/thunk.ts b/ember-resources/src/core/types/thunk.ts new file mode 100644 index 000000000..58cda95e9 --- /dev/null +++ b/ember-resources/src/core/types/thunk.ts @@ -0,0 +1,150 @@ +import type { ArgsWrapper, EmptyObject, GetOrElse, NoArgs } from './base'; + +/** + * @private utility type + * Used in the Resource.from methods. + * Only takes fully defined args (including positional and named keys) + */ +export type AsThunk> = Expanded extends NoArgs + ? () => NoArgs | [] | EmptyObject | undefined | void + : () => LoosenThunkReturn; + +/** + * @private utility type + * + * Converts a variety of types to the expanded arguments type + * that aligns with the 'Args' portion of the 'Signature' types + * from ember's helpers, modifiers, components, etc + * + * tl;dr: + * converts Signature-style args o thunk/glimmer args + * - { Named: ... } => { named: ... } + * - { Positional: ... } => { positional: ... } + * + * This is the *full* type, which is useful for then loosening later + * + */ +// export type ExpandThunkReturn = T extends any[] +// ? ThunkReturnFor<{ positional: T }> +// : T extends { positional: unknown[] } +// ? ThunkReturnFor +// : T extends { named: unknown } +// ? ThunkReturnFor +// : T extends object +// ? ThunkReturnFor<{ named: T }> +// : never; + +/** + * @private utility type + * + * Normalizes the different Arg-types into the thunk-args type, which is + * lowercase positional and named, where as the Signature-args are + * uppercase + */ +export type ThunkReturnFor = S extends { named?: object; positional?: unknown[] } + ? { + positional: GetOrElse; + named: GetOrElse; + } + : S extends { Named?: object; Positional?: unknown[] } + ? { + positional: GetOrElse; + named: GetOrElse; + } + : NoArgs; + +/** + * @private utility type + * + * Because our thunks have a couple shorthands for positional-only + * and named-only usages, this utility type expands a full thunk-arg type + * to include those optional shorthands + */ +export type LoosenThunkReturn = Args extends { positional: unknown[]; named: EmptyObject } + ? { positional: Args['positional'] } | Args['positional'] + : Args extends { positional: []; named: object } + ? { named: Args['named'] } | Args['named'] + : Args; + +/** + * A generic function type that represents the various formats a Thunk can be in. + * + * - The thunk is "just a function" that allows tracked data to be lazily consumed by the resource. + * + * Note that thunks are awkward when they aren't required -- they may even be awkward + * when they are required. Whenever possible, we should rely on auto-tracking, such as + * what [[trackedFunction]] provides. + * + * So when and why are thunks needed? + * - when we want to manage reactivity *separately* from a calling context. + * - in many cases, the thunk is invoked during setup and update of various Resources, + * so that the setup and update evaluations can "entangle" with any tracked properties + * accessed within the thunk. This allows changes to those tracked properties to + * cause the Resources to (re)update. + * + * The args thunk accepts the following data shapes: + * ``` + * () => [an, array] + * () => ({ hello: 'there' }) + * () => ({ named: {...}, positional: [...] }) + * ``` + * + * #### An array + * + * when an array is passed, inside the Resource, `this.args.named` will be empty + * and `this.args.positional` will contain the result of the thunk. + * + * _for function resources, this is the only type of thunk allowed._ + * + * #### An object of named args + * + * when an object is passed where the key `named` is not present, + * `this.args.named` will contain the result of the thunk and `this.args.positional` + * will be empty. + * + * #### An object containing both named args and positional args + * + * when an object is passed containing either keys: `named` or `positional`: + * - `this.args.named` will be the value of the result of the thunk's `named` property + * - `this.args.positional` will be the value of the result of the thunk's `positional` property + * + * This is the same shape of args used throughout Ember's Helpers, Modifiers, etc + * + * #### For fine-grained reactivity + * + * you may opt to use an object of thunks when you want individual properties + * to be reactive -- useful for when you don't need or want to cause whole-resource + * lifecycle events. + * + * ``` + * () => ({ + * foo: () => this.foo, + * bar: () => this.bar, + * }) + * ``` + * Inside a class-based [[Resource]], this will be received as the named args. + * then, you may invoke `named.foo()` to evaluate potentially tracked data and + * have automatic updating within your resource based on the source trackedness. + * + * ``` + * class MyResource extends Resource { + * modify(_, named) { this.named = named }; + * + * get foo() { + * return this.named.foo(); + * } + * } + * ``` + */ +export type Thunk = + // No Args + | (() => []) + | (() => void) + | (() => undefined) + // plain array / positional args + | (() => ThunkReturnFor['positional']) + // plain named args + | (() => ThunkReturnFor['named']) + // both named and positional args... but why would you choose this? :upsidedownface: + | (() => Partial>) + | (() => ThunkReturnFor); diff --git a/ember-resources/src/index.ts b/ember-resources/src/index.ts index eaa103d9e..900b0472f 100644 --- a/ember-resources/src/index.ts +++ b/ember-resources/src/index.ts @@ -7,5 +7,4 @@ export { use } from './core/use'; export { cell } from 'util/cell'; // Public Type Utilities -export type { ExpandArgs } from './core/class-based/types'; -export type { ArgsWrapper, Thunk } from './core/types'; +export type { ArgsWrapper, ExpandArgs, Thunk } from './core/types'; diff --git a/ember-resources/src/util/map.ts b/ember-resources/src/util/map.ts index 6632e94e3..b451f6e33 100644 --- a/ember-resources/src/util/map.ts +++ b/ember-resources/src/util/map.ts @@ -3,6 +3,8 @@ import { assert } from '@ember/debug'; import { Resource } from '../core/class-based'; +import type { Named, Positional } from '../core/types'; + /** * Public API of the return value of the [[map]] resource. */ @@ -178,11 +180,15 @@ export function map( ) { let { data, map } = options; - let resource = TrackedArrayMap.from(destroyable, () => { + /** + * The passing here is hacky, but required until the min-supported + * typescript version is 4.7 + */ + let resource = TrackedArrayMap.from>(destroyable, () => { let reified = data(); return { positional: [reified], named: { map } }; - }) as TrackedArrayMap; + }); /** * This is what allows square-bracket index-access to work. @@ -208,10 +214,12 @@ export function map( }) as unknown as MappedArray; } -type PositionalArgs = [E[]]; -interface NamedArgs { - map: (element: E) => Result; -} +type Args = { + Positional: [E[]]; + Named: { + map: (element: E) => Result; + }; +}; const AT = Symbol('__AT__'); @@ -219,10 +227,7 @@ const AT = Symbol('__AT__'); * @private */ export class TrackedArrayMap - extends Resource<{ - positional: PositionalArgs; - named: NamedArgs; - }> + extends Resource> implements MappedArray { // Tells TS that we can array-index-access @@ -233,7 +238,7 @@ export class TrackedArrayMap @tracked private declare _records: (Element & object)[]; @tracked private declare _map: (element: Element) => MappedTo; - modify([data]: PositionalArgs, { map }: NamedArgs) { + modify([data]: Positional>, { map }: Named>) { assert( `Every entry in the data passed ta \`map\` must be an object.`, data.every((datum) => typeof datum === 'object') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 744dc6fc5..088e60856 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -97,6 +97,7 @@ importers: ember-async-data: ^0.6.0 ember-template-lint: 3.16.0 eslint: ^7.32.0 + expect-type: ^0.13.0 npm-run-all: 4.1.5 rollup: 2.78.1 rollup-plugin-terser: ^7.0.2 @@ -137,6 +138,7 @@ importers: ember-async-data: 0.6.0_@babel+core@7.18.13 ember-template-lint: 3.16.0 eslint: 7.32.0 + expect-type: 0.13.0 npm-run-all: 4.1.5 rollup: 2.78.1 rollup-plugin-terser: 7.0.2_rollup@2.78.1 diff --git a/testing/ember-app/app/components/glint-colocated.ts b/testing/ember-app/app/components/glint-colocated.ts index 5d49e43a9..bf92c2659 100644 --- a/testing/ember-app/app/components/glint-colocated.ts +++ b/testing/ember-app/app/components/glint-colocated.ts @@ -13,7 +13,7 @@ export default class GlintTest extends Component { clock = clock; overInvalidatingClock = overInvalidatingClock; - calculator = Calculator.from(this); + calculator = Calculator.from(this, () => ({})); doubler = Doubler.from(this, () => [this.input]); decoratorLess = resource(this, () => { diff --git a/testing/ember-app/app/components/glint-gts.gts b/testing/ember-app/app/components/glint-gts.gts index 93b94a7db..ac6e494cc 100644 --- a/testing/ember-app/app/components/glint-gts.gts +++ b/testing/ember-app/app/components/glint-gts.gts @@ -15,7 +15,7 @@ const SomeClocks =