Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: type checked builders and inferred request types from builders #9359

Merged
merged 8 commits into from
Apr 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 40 additions & 3 deletions guides/typescript/3-typing-models.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Typing Models
# Typing Models & Transforms

## ResourceType

Expand All @@ -15,21 +15,58 @@ export default class User extends Model {
}
```

The benefit of the above is that the value of ResourceType is readable at runtime and thus easy to debug.
However, you can also choose to do this via types only:

```ts
import Model, { attr } from '@ember-data/model';
import type { ResourceType } from '@warp-drive/core-types/symbols';

export default class User extends Model {
@attr declare name: string;

declare [ResourceType]: 'user';
}
```

EmberData will never access ResourceType as an actual value, these brands are *purely* for type inference.

## Transforms

Transforms with a `TransformName` brand will have their type and options validated. Once we move to stage-3 decorators, the signature of the field would also be validated against the transform.

Example: Typing a Transform

```ts
import type { TransformName } from '@warp-drive/core-types/symbols';

export default class BigIntTransform {
deserialize(serialized: string): BigInt | null {
return !serialized || serialized === '' ? null : BigInt(serialized + 'n');
}
serialize(deserialized: BigInt | null): string | null {
return !deserialized ? null : String(deserialized);
}

declare [TransformName]: 'big-int';

static create() {
return new this();
}
}
```

Example: Using Transforms

```ts
import Model, { attr } from '@ember-data/model';
import type { StringTransform } from '@ember-data/serializer/transforms';
import { ResourceType } from '@warp-drive/core-types/symbols';
import type { ResourceType } from '@warp-drive/core-types/symbols';

export default class User extends Model {
@attr<StringTransform>('string') declare name: string;

[ResourceType] = 'user' as const;
declare [ResourceType]: 'user';
}
```

Expand Down
65 changes: 65 additions & 0 deletions guides/typescript/4-typing-requests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Typing Requests & Builders

## How it works (but what not to do in the general case)

`requestManager.request` and `store.request` each take a generic that can be used to set
the return type of the content of the associated request.

```ts
const { content } = await store.request<User>({ ... });

// here content will be typed as a User
```

> [!CAUTION]
> Note that this puts the burden on you to ensure the return type accurately portrays the result!

In all cases, the response will be a `StructuredDocument<T>` where `T` is the content type provided.

This approach allows for a lot of flexibility in designing great sugar overtop of the request infrastructure, but again, limits the amount of safety provided and should be used with great caution.

A better approach is to use builders and set the generic via inference.

## Setting Content's Type from a Builder

The signature for `request` will infer the generic for the content type from a special brand on the options passed to it.

```ts
type MyRequest {
// ...
[RequestSignature]: Collection<User>
}

function buildMyRequest(...): MyRequest { /* ... */ }

const { content } = await store.request(
buildMyRequest(...)
);

// here content will be set to `Collection<User>`
```

## Advanced Builders

Because builders are just functions that produce a request options object, and because this object can be branded with
the type signature of the response, we can use this to create
advanced more-strongly-typed systems.

For instance, imagine you had a query builder that validated
and linted the query against a backing schema, such as you might
get with GraphQL

```ts
const { content } = await store.request(
gql`query withoutVariable {
continents {
code
name
countries {
name
capital
}
}
}`
);
```
6 changes: 2 additions & 4 deletions guides/typescript/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,8 @@ the following two sections
- [Using Types Packages](./1-configuration.md#using-types-packages)
- Usage
- [Why Brands](./2-why-brands.md)
- [Typing Models](./3-typing-models.md)
- Typing Transforms
- Typing Requests
- Typing Builders
- [Typing Models & Transforms](./3-typing-models.md)
- [Typing Requests & Builders](./4-typing-requests.md)
- Typing Handlers
- Using Store APIs

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"test": "pnpm turbo test --concurrency=1",
"test:production": "pnpm turbo test:production --concurrency=1",
"test:try-one": "pnpm --filter main-test-app run test:try-one",
"test:docs": "pnpm build:docs && pnpm run -r --workspace-concurrency=-1 --if-present test:docs",
"test:docs": "FORCE_COLOR=2 pnpm build:docs && pnpm run -r --workspace-concurrency=-1 --if-present test:docs",
"test:blueprints": "pnpm run -r --workspace-concurrency=-1 --if-present test:blueprints",
"test:fastboot": "pnpm run -r --workspace-concurrency=-1 --if-present test:fastboot",
"test:embroider": "pnpm run -r ---workspace-concurrency=-1 --if-present test:embroider",
Expand Down
17 changes: 13 additions & 4 deletions packages/active-record/src/-private/builders/find-record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,17 @@ import { underscore } from '@ember/string';
import { pluralize } from 'ember-inflector';

import { buildBaseURL, buildQueryParams, type FindRecordUrlOptions } from '@ember-data/request-utils';
import type { TypeFromInstance } from '@warp-drive/core-types/record';
import type {
ConstrainedRequestOptions,
FindRecordOptions,
FindRecordRequestOptions,
RemotelyAccessibleIdentifier,
} from '@warp-drive/core-types/request';
import type { SingleResourceDataDocument } from '@warp-drive/core-types/spec/document';

import { copyForwardUrlOptions, extractCacheOptions } from './-utils';

type FindRecordOptions = ConstrainedRequestOptions & {
include?: string | string[];
};
export type FindRecordResultDocument<T> = Omit<SingleResourceDataDocument<T>, 'data'> & { data: T };

/**
* Builds request options to fetch a single resource by a known id or identifier
Expand Down Expand Up @@ -76,10 +76,19 @@ type FindRecordOptions = ConstrainedRequestOptions & {
* @param identifier
* @param options
*/
export function findRecord<T>(
identifier: RemotelyAccessibleIdentifier<TypeFromInstance<T>>,
options?: FindRecordOptions<T>
): FindRecordRequestOptions<T, FindRecordResultDocument<T>>;
export function findRecord(
identifier: RemotelyAccessibleIdentifier,
options?: FindRecordOptions
): FindRecordRequestOptions;
export function findRecord<T>(
type: TypeFromInstance<T>,
id: string,
options?: FindRecordOptions<T>
): FindRecordRequestOptions<T, FindRecordResultDocument<T>>;
export function findRecord(type: string, id: string, options?: FindRecordOptions): FindRecordRequestOptions;
export function findRecord(
arg1: string | RemotelyAccessibleIdentifier,
Expand Down
14 changes: 14 additions & 0 deletions packages/active-record/src/-private/builders/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { pluralize } from 'ember-inflector';

import { buildBaseURL, buildQueryParams, type QueryUrlOptions } from '@ember-data/request-utils';
import type { QueryParamsSource } from '@warp-drive/core-types/params';
import type { TypeFromInstance } from '@warp-drive/core-types/record';
import type { ConstrainedRequestOptions, QueryRequestOptions } from '@warp-drive/core-types/request';
import type { CollectionResourceDataDocument } from '@warp-drive/core-types/spec/document';

import { copyForwardUrlOptions, extractCacheOptions } from './-utils';

Expand Down Expand Up @@ -61,6 +63,18 @@ import { copyForwardUrlOptions, extractCacheOptions } from './-utils';
* @param query
* @param options
*/
export function query<T>(
type: TypeFromInstance<T>,
// eslint-disable-next-line @typescript-eslint/no-shadow
query?: QueryParamsSource,
options?: ConstrainedRequestOptions
): QueryRequestOptions<T, CollectionResourceDataDocument<T>>;
export function query(
type: string,
// eslint-disable-next-line @typescript-eslint/no-shadow
query?: QueryParamsSource,
options?: ConstrainedRequestOptions
): QueryRequestOptions;
export function query(
type: string,
// eslint-disable-next-line @typescript-eslint/no-shadow
Expand Down
12 changes: 12 additions & 0 deletions packages/active-record/src/-private/builders/save-record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ function isExisting(identifier: StableRecordIdentifier): identifier is StableExi
* @param record
* @param options
*/
export function deleteRecord<T>(record: T, options?: ConstrainedRequestOptions): DeleteRequestOptions<T>;
export function deleteRecord(record: unknown, options?: ConstrainedRequestOptions): DeleteRequestOptions;
export function deleteRecord(record: unknown, options: ConstrainedRequestOptions = {}): DeleteRequestOptions {
const identifier = recordIdentifierFor(record);
assert(`Expected to be given a record instance`, identifier);
Expand Down Expand Up @@ -147,6 +149,8 @@ export function deleteRecord(record: unknown, options: ConstrainedRequestOptions
* @param record
* @param options
*/
export function createRecord<T>(record: T, options?: ConstrainedRequestOptions): CreateRequestOptions<T>;
export function createRecord(record: unknown, options?: ConstrainedRequestOptions): CreateRequestOptions;
export function createRecord(record: unknown, options: ConstrainedRequestOptions = {}): CreateRequestOptions {
const identifier = recordIdentifierFor(record);
assert(`Expected to be given a record instance`, identifier);
Expand Down Expand Up @@ -220,6 +224,14 @@ export function createRecord(record: unknown, options: ConstrainedRequestOptions
* @param record
* @param options
*/
export function updateRecord<T>(
record: T,
options?: ConstrainedRequestOptions & { patch?: boolean }
): UpdateRequestOptions<T>;
export function updateRecord(
record: unknown,
options?: ConstrainedRequestOptions & { patch?: boolean }
): UpdateRequestOptions;
export function updateRecord(
record: unknown,
options: ConstrainedRequestOptions & { patch?: boolean } = {}
Expand Down
90 changes: 90 additions & 0 deletions packages/core-types/src/record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,93 @@ export type TypeFromInstance<T> = T extends TypedRecordInstance ? T[typeof Resou
* @typedoc
*/
export type TypeFromInstanceOrString<T> = T extends TypedRecordInstance ? T[typeof ResourceType] : string;

type Unpacked<T> = T extends (infer U)[] ? U : T;
type NONE = { __NONE: never };

type __InternalExtract<
T extends TypedRecordInstance,
V extends TypedRecordInstance,
IncludePrefix extends boolean,
Ignore,
Pre extends string,
> =
// if we extend T, we return the leaf value
V extends T
? IncludePrefix extends false
? V[typeof ResourceType]
: Pre
: // else if we are in Ignore we add the lead and exit
V extends Ignore
? IncludePrefix extends false
? V[typeof ResourceType]
: Pre
: // else add T to Ignore and recurse
ExtractUnion<V, IncludePrefix, Ignore | T, Pre>;

type __ExtractIfRecord<
T extends TypedRecordInstance,
V,
IncludePrefix extends boolean,
Ignore,
Pre extends string,
> = V extends TypedRecordInstance ? __InternalExtract<T, V, IncludePrefix, Ignore, Pre> : never;

type _ExtractUnion<T extends TypedRecordInstance, IncludePrefix extends boolean, Ignore, Pre> = {
// for each string key in the record,
[K in keyof T]: K extends string
? // we recursively extract any values that resolve to a TypedRecordInstance
__ExtractIfRecord<T, Unpacked<Awaited<T[K]>>, IncludePrefix, Ignore, Pre extends string ? `${Pre}.${K}` : K>
: never;
// then we return any value that is not 'never'
}[keyof T];

/**
* A Utility that extracts either resource types or resource paths from a TypedRecordInstance.
*
* Its limitations are mostly around its intentional non-recursiveness. It presumes that APIs which
* implement includes will not allow cyclical include paths, and will collapse includes by type.
*
* This follows closer to the JSON:API fields spec than to the includes spec in nature, but in
* practice it is so impracticle for an API to allow z-algo include paths that this is probably
* reasonable.
*
* We may need to revisit this in the future, opting to either make this restriction optional or
* to allow for other strategies.
*
* There's a 90% chance this particular implementation belongs being in the JSON:API package instead
* of core-types, but it's here for now.
*
* @typedoc
*/
type ExtractUnion<
T extends TypedRecordInstance,
IncludePrefix extends boolean = false,
Ignore = NONE,
Pre = NONE,
> = Exclude<
IncludePrefix extends true
? // if we want to include prefix, we union with the prefix. Outer Exclude will filter any "NONE" types
_ExtractUnion<T, IncludePrefix, Ignore, Pre> | Pre
: // Else we just union the types.
_ExtractUnion<T, IncludePrefix, Ignore, Pre> | T[typeof ResourceType],
NONE
>;

/**
* A utility that provides the union of all ResourceName for all potential
* includes for the given TypedRecordInstance.
*
* @typedoc
*/
export type ExtractSuggestedCacheTypes<T extends TypedRecordInstance> = ExtractUnion<T>; // ToPaths<ExpandIgnore<T, true>, false>;

/**
* A utility that provides the union type of all valid include paths for the given
* TypedRecordInstance.
*
* Cyclical paths are filtered out.
*
* @typedoc
*/
export type Includes<T extends TypedRecordInstance> = ExtractUnion<T, true>; // ToPaths<ExpandIgnore<T>>;
Loading
Loading