From 8948e56a9fe84b5b62dce1cf921d166134297c05 Mon Sep 17 00:00:00 2001 From: Krystan HuffMenne Date: Tue, 17 Dec 2024 14:56:22 -0800 Subject: [PATCH] Update EmberData TS guides --- .../converting-an-app.md | 3 +- .../typescript/core-concepts/ember-data.md | 275 +++++++++--------- 2 files changed, 135 insertions(+), 143 deletions(-) diff --git a/guides/release/typescript/application-development/converting-an-app.md b/guides/release/typescript/application-development/converting-an-app.md index d8cded2a57..79b8447caf 100644 --- a/guides/release/typescript/application-development/converting-an-app.md +++ b/guides/release/typescript/application-development/converting-an-app.md @@ -19,7 +19,7 @@ Add a `tsconfig.json` file to the root of your project. Copy its contents from t ### Set Up TypeScript for EmberData - +Follow the instructions in the [EmberData Typescript Guides][ED-ts-guides]. ### Enable TypeScript Transpilation for Builds @@ -122,6 +122,7 @@ If you're migrating from `ember-cli-typescript` to Ember's native TypeScript sup +[ED-ts-guides]: ../../core-concepts/ember-data/#toc_adding-emberdata-types-to-an-existing-typescript-app [getting started]: ../../getting-started/ [legacy]: ../../additional-resources/legacy/ [packages]: ../../getting-started/#toc_packages-to-support-typescript diff --git a/guides/release/typescript/core-concepts/ember-data.md b/guides/release/typescript/core-concepts/ember-data.md index 438de2ae19..1aca8f0818 100644 --- a/guides/release/typescript/core-concepts/ember-data.md +++ b/guides/release/typescript/core-concepts/ember-data.md @@ -1,11 +1,3 @@ -## Adding EmberData Types to an Existing TypeScript App - -```shell -npx warp-drive retrofit types@beta -``` - - - In this section, we cover how to use TypeScript effectively with specific EmberData APIs (anything you'd find under the `@ember-data` package namespace). We do _not_ cover general usage of EmberData; instead, we assume that as background knowledge. Please see the [EmberData Guides][ED-guides] and [API docs][ED-api-docs]! @@ -16,10 +8,7 @@ We do _not_ cover general usage of EmberData; instead, we assume that as backgro
Zoey says...

- These guides currently assume you are using the EmberData @types packages in conjunction with the Ember @types packages. -

-

- For improved (albeit less stable) types, you can switch to EmberData's alpha native types, documented at this link. Using the EmberData alpha native types will also require switching to the Ember native types, which are guaranteed to always be 100% correct and 100% up to date! + The following content applies to the native EmberData types, which are currently considered "unstable" (though in practice, they've been pretty stable as of late). These guides may change as the EmberData types are finalized.

@@ -29,20 +18,33 @@ We do _not_ cover general usage of EmberData; instead, we assume that as backgro ## Models -EmberData models are normal TypeScript classes, but with properties decorated to define how the model represents an API resource and relationships to other resources. The decorators the library supplies "just work" with TypeScript at runtime, but require type annotations to be useful with TypeScript. Additionally, you must register each model with the [`ModelRegistry`][ED-registry] as shown in the examples below. +EmberData models are normal TypeScript classes, but with properties decorated to define how the model represents an API resource and relationships to other resources. The decorators the library supplies "just work" with TypeScript at runtime, but require type annotations to be useful with TypeScript. Additionally, you must include the model's ["brand"][brand] to ensure that the EmberData store APIs return the correct types. + +For example, here we add the `Type` brand to the `user` model: -### `@attr` +```ts {data-filename="app/models/user.ts" data-diff="+2,+5"} +import Model from '@ember-data/model'; +import type { Type } from '@warp-drive/core-types/symbols'; -The type returned by the `@attr` [decorator][] is whatever [Transform][transform-api-docs] is applied via the invocation. See our [overview of Transforms][transforms] for more information. +export default class User extends Model { + declare [Type]: 'user'; +} +``` + +EmberData will never access Type as an actual value, these brands are _purely_ for type inference. + +### Attributes + +The type returned by the `@attr` [decorator][] is determined by whatever [Transform][transform-api-docs] is applied via the invocation. See our [overview of Transforms][transforms] for more information. If you supply no argument to `@attr`, the value is passed through without transformation. If you supply one of the built-in transforms, you will get back a corresponding type: -- `@attr('string')` → `string` -- `@attr('number')` → `number` -- `@attr('boolean')` → `boolean` -- `@attr('date')` → `Date` +- `@attr('string')` → `string | null` +- `@attr('number')` → `number | null` +- `@attr('boolean')` → `boolean | null` +- `@attr('date')` → `Date | null` If you supply a custom transform, you will get back the type returned by your transform. @@ -50,31 +52,22 @@ So, for example, you might write a class like this: ```typescript {data-filename="app/models/user.ts"} import Model, { attr } from '@ember-data/model'; -import CustomType from '../transforms/custom-transform'; +import type { Type } from '@warp-drive/core-types/symbols'; +import CustomType from '@my-app/transforms/custom-transform'; export default class User extends Model { - @attr - declare name?: string; + @attr declare name?: string; - @attr('number') - declare age: number; + @attr('number') declare age?: number | null; - @attr('boolean') - declare isAdmin: boolean; + @attr('boolean') declare isAdmin?: boolean | null; - @attr('custom-transform') - declare myCustomThing: CustomType; -} + @attr('custom-transform') declare myCustomThing?: CustomType; -declare module 'ember-data/types/registries/model' { - export default interface ModelRegistry { - user: User; - } + declare [Type]: 'user'; } ``` -#### Type Safety for Model Attributes - Even more than with decorators in general, you should be careful when deciding whether to mark a property as [optional `?`][optional] or definitely present (no annotation): EmberData will default to leaving a property empty if it is not supplied by the API or by a developer when creating it. That is: the _default_ for EmberData corresponds to an optional field on the model. The _safest_ type you can write for an EmberData model, therefore, leaves every property optional: this is how models _actually_ behave. If you choose to mark properties as definitely present by leaving off the `?`, you should take care to guarantee that this is a guarantee your API upholds, and that ever time you create a record from within the app, _you_ uphold those guarantees. @@ -83,201 +76,199 @@ One way to make this safer is to supply a default value using the `defaultValue` ```typescript {data-filename="app/models/user.ts"} import Model, { attr } from '@ember-data/model'; +import type { Type } from '@warp-drive/core-types/symbols'; +import CustomType from '@my-app/transforms/custom-transform'; export default class User extends Model { - @attr - declare name?: string; + @attr declare name?: string; - @attr('number', { defaultValue: 13 }) - declare age: number; + @attr('number', { defaultValue: 13 }) declare age: number; - @attr('boolean', { defaultValue: false }) - declare isAdmin: boolean; -} + @attr('boolean', { defaultValue: false }) declare isAdmin: boolean; -declare module 'ember-data/types/registries/model' { - export default interface ModelRegistry { - user: User; - } + declare [Type]: 'user'; } ``` -### Relationships +### Async BelongsTo Relationships -Relationships between models in EmberData rely on importing the related models, like `import User from './user';`. This, naturally, can cause a recursive loop, as `/app/models/post.ts` imports `User` from `/app/models/user.ts`, and `/app/models/user.ts` imports `Post` from `/app/models/post.ts`. Recursive importing triggers an [`import/no-cycle`][import-no-cycle] error from ESLint. +If the `@belongsTo` is `{ async: true }` (the default), the type is `AsyncBelongsTo`, where `Model` is the type of the model you are creating a relationship to. Additionally, pass the `Model` type as a generic to the `@belongsTo` decorator to ensure that the inverse relationship is validated. -To avoid these errors, use [type-only imports][type-only-imports]: +```ts {data-filename="app/models/user.ts"} +import Model, { belongsTo, AsyncBelongsTo } from '@ember-data/model'; +import type Address from './address'; +import type { Type } from '@warp-drive/core-types/symbols'; -```typescript -import type User from './user'; -``` +export default class User extends Model { + @belongsTo
('address', { async: true, inverse: null }) + declare address: AsyncBelongsTo
; -#### `@belongsTo` + declare [Type]: 'user'; +} +``` -The type returned by the `@belongsTo` decorator depends on whether the relationship is `{ async: true }` (which it is by default). +Async BelongsTo relationships are type-safe to define as always present. Accessing an async relationship will _always_ return an `AsyncBelongsTo` object, which itself may or may not ultimately resolve to a value—depending on the API response—but will always be present itself. -- If the value is `true`, the type you should use is `AsyncBelongsTo`, where `Model` is the type of the model you are creating a relationship to. -- If the value is `false`, the type is `Model`, where `Model` is the type of the model you are creating a relationship to. +### Sync BelongsTo Relationships -So, for example, you might define a class like this: +If the `@belongsTo` is `{ async: false }`, the type you should use is `Model | null`, where `Model` is the type of the model you are creating a relationship to. Again, you should pass the `Model` type as a generic to the `@belongsTo` decorator to ensure that the inverse relationship is validated. -```typescript {data-filename="app/models/post.ts"} -import Model, { belongsTo, type AsyncBelongsTo } from '@ember-data/model'; -import type User from './user'; -import type Site from './site'; +```ts {data-filename="app/models/user.ts"} +import Model, { belongsTo } from '@ember-data/model'; +import type Address from './address'; +import type { Type } from '@warp-drive/core-types/symbols'; -export default class Post extends Model { - @belongsTo('user') - declare user: AsyncBelongsTo; +export default class User extends Model { + @belongsTo
('address', { async: false, inverse: null }) + declare address: Address | null; - @belongsTo('site', { async: false }) - declare site: Site; + declare [Type]: 'user'; } +``` -declare module 'ember-data/types/registries/model' { - export default interface ModelRegistry { - post: Post; - } +### Async HasMany Relationships + +If the `@hasMany` is `{ async: true }` (the default), the type is `AsyncHasMany`, where `Model` is the type of the model you are creating a relationship to. Additionally, pass the `Model` type as a generic to the `@hasMany` decorator to ensure that the inverse relationship is validated. + +```ts {data-filename="app/models/user.ts"} +import Model, { hasMany, AsyncHasMany } from '@ember-data/model'; +import type Post from './post'; +import type { Type } from '@warp-drive/core-types/symbols'; + +export default class User extends Model { + @hasMany('post', { async: true, inverse: 'author' }) + declare posts: AsyncHasMany; + + declare [Type]: 'user'; } ``` -These are _type_-safe to define as always present, that is to leave off the `?` optional marker: +### Sync HasMany Relationships -- accessing an async relationship will always return an `AsyncBelongsTo` object, which itself may or may not ultimately resolve to a value—depending on the API response—but will always be present itself. -- accessing a non-async relationship which is known to be associated but has not been loaded will trigger an error, so all access to the property will be safe _if_ it resolves at all. +If the `@hasMany` is `{ async: false }`, the type is `HasMany`, where `Model` is the type of the model you are creating a relationship to. Additionally, pass the `Model` type as a generic to the `@hasMany` decorator to ensure that the inverse relationship is validated. -Note, however, that this type-safety is not a guarantee of there being no runtime error: you still need to uphold the contract for non-async relationships (that is: loading the data first, or side-loading it with the request) to avoid throwing an error! +```ts {data-filename="app/models/user.ts"} +import Model, { hasMany, HasMany } from '@ember-data/model'; +import type Post from './post'; +import type { Type } from '@warp-drive/core-types/symbols'; -#### `@hasMany` +export default class User extends Model { + @hasMany('post', { async: false, inverse: 'author' }) + declare posts: HasMany; + + declare [Type]: 'user'; +} +``` -The type returned by the `@hasMany` decorator depends on whether the relationship is `{ async: true }` (which it is by default). +### A Note About Recursive Imports -- If the value is `true`, the type you should use is `AsyncHasMany`, where `Model` is the type of the model you are creating a relationship to. -- If the value is `false`, the type is `SyncHasMany`, where `Model` is the type of the model you are creating a relationship to. +Relationships between models in EmberData rely on importing the related models, like `import User from './user';`. This, naturally, can cause a recursive loop, as `/app/models/post.ts` imports `User` from `/app/models/user.ts`, and `/app/models/user.ts` imports `Post` from `/app/models/post.ts`. Recursive importing triggers an [`import/no-cycle`][import-no-cycle] error from ESLint. -So, for example, you might define a class like this: +To avoid these errors, use [type-only imports][type-only-imports]: -```typescript {data-filename="app/models/thread.ts"} -import Model, { - hasMany, - type AsyncHasMany, - type SyncHasMany, -} from '@ember-data/model'; -import type Comment from './comment'; +```typescript import type User from './user'; +``` -export default class Thread extends Model { - @hasMany('comment') - declare comments: AsyncHasMany; +### A Note About Open Types - @hasMany('user', { async: false }) - declare participants: SyncHasMany; -} +When accessing `this.belongsTo` or `this.hasMany` from within a model, you'll need to pass the relationship `Model` type and the string key as generics, like so: + +```ts {data-filename="app/models/user.ts"} +import Model, { hasMany, AsyncHasMany } from '@ember-data/model'; +import type Post from './post'; +import type { Type } from '@warp-drive/core-types/symbols'; + +export default class User extends Model { + @hasMany('post', { async: true, inverse: 'author' }) + declare posts: AsyncHasMany; -declare module 'ember-data/types/registries/model' { - export default interface ModelRegistry { - thread: Thread; + get postIdList(): string[] { + return this.hasMany('posts').ids(); } + + declare [Type]: 'user'; } ``` -The same basic rules about the safety of these lookups as with `@belongsTo` apply to these types. The difference is just that in `@hasMany` the resulting types are _arrays_ rather than single objects. +The reason is that `this.belongsTo` and `this.hasMany` will infer an 'open' type for `this`, meaning that `this` can still be modified. For this reason, it's not able to index the keys of the model. As a workaround, pass in the 'concrete' or `closed` type for proper resolution. ## Transforms In EmberData, `@attr` defines an [attribute on a Model][model-attrs]. By default, attributes are passed through as-is, however you can specify an optional type to have the value automatically transformed. EmberData ships with four basic transform types: `string`, `number`, `boolean` and `date`. -You can define your own transforms by sub-classing [Transform][transform-guides]. EmberData transforms are normal TypeScript classes. The return type of `deserialize` method becomes type of the model class property. +EmberData Transforms[transform-guides] are normal TypeScript classes. The return type of `deserialize` method becomes type of the model class property. -You may define your own transforms in TypeScript like so: +Transforms with a `Type` brand will have their type and options validated. -```typescript {data-filename="app/transforms/coordinate-point.ts"} -import Transform from '@ember-data/serializer/transform'; +### Example: Typing a Transform -export type CoordinatePoint = { - x: number; - y: number; -}; +```ts {data-filename="app/transforms/big-int.ts"} +import type { Type } from '@warp-drive/core-types/symbols'; -export default class CoordinatePointTransform extends Transform { - deserialize(serialized): CoordinatePoint { - return { x: value[0], y: value[1] }; +export default class BigIntTransform { + deserialize(serialized: string): BigInt | null { + return !serialized || serialized === '' ? null : BigInt(serialized + 'n'); } - - serialize(value): number { - return [value.x, value.y]; + serialize(deserialized: BigInt | null): string | null { + return !deserialized ? null : String(deserialized); } -} -declare module 'ember-data/types/registries/transform' { - export default interface TransformRegistry { - 'coordinate-point': CoordinatePointTransform; + declare [Type]: 'big-int'; + + static create() { + return new this(); } } ``` -```typescript {data-filename="app/models/cursor.ts"} +### Example: Using Transforms + +```ts {data-filename="app/models/user.ts"} import Model, { attr } from '@ember-data/model'; -import { CoordinatePoint } from 'my-app/transforms/coordinate-point'; +import type { StringTransform } from '@ember-data/serializer/transforms'; +import type { Type } from '@warp-drive/core-types/symbols'; -export default class Cursor extends Model { - @attr('coordinate-point') declare position: CoordinatePoint; -} +export default class User extends Model { + @attr('string') declare name: string; -declare module 'ember-data/types/registries/model' { - export default interface ModelRegistry { - cursor: Cursor; - } + declare [Type]: 'user'; } ``` -Note that you should declare your own transform under [`TransformRegistry`][ED-registry] to make `@attr` to work with your transform. - ## Serializers and Adapters -EmberData serializers and adapters are normal TypeScript classes. The only related gotcha is that you must [register][ED-registry] them with a declaration: +EmberData serializers and adapters are normal TypeScript classes. ```typescript {data-filename="app/serializers/user-meta.ts"} import Serializer from '@ember-data/serializer'; export default class UserMeta extends Serializer {} - -declare module 'ember-data/types/registries/serializer' { - export default interface SerializerRegistry { - 'user-meta': UserMeta; - } -} ``` ```typescript {data-filename="app/adapters/user.ts"} import Adapter from '@ember-data/adapter'; export default class User extends Adapter {} - -declare module 'ember-data/types/registries/adapter' { - export default interface AdapterRegistry { - user: User; - } -} ``` -## EmberData Registries +## Adding EmberData Types to an Existing TypeScript App -We use [registry][] approach for EmberData type lookups with string keys. As a result, once you add the module and interface definitions for each model, transform, serializer, and adapter in your app, you will automatically get type-checking and autocompletion and the correct return types for functions like `findRecord`, `queryRecord`, `adapterFor`, `serializerFor`, etc. No need to try to write out those types; just write your EmberData calls like normal and everything _should_ just work. That is, writing `this.store.findRecord('user', 1)` will give you back a `Promise`. +The process for adding EmberData types to an existing TypeScript app is a work in progress. You can find the latest docs in the [EmberData repository][ED-ts-guides]. [decorator]: ../../additional-resources/gotchas/#toc_decorators [ED-guides]: ../../../models/ -[ED-registry]: ./#toc_emberdata-registries [model-attrs]: ../../../models/defining-models/ -[registry]: ../../additional-resources/gotchas/#toc_registries [transforms]: ./#toc_transforms [transform-guides]: ../../../models/defining-models/#toc_custom-transforms +[brand]: https://github.com/emberjs/data/blob/main/guides/typescript/2-why-brands.md [ED-api-docs]: https://api.emberjs.com/ember-data/release +[ED-ts-guides]: https://github.com/emberjs/data/blob/main/guides/typescript/index.md [import-no-cycle]: https://github.com/import-js/eslint-plugin-import/blob/main/docs/rules/no-cycle.md [optional]: https://www.typescriptlang.org/docs/handbook/2/objects.html#optional-properties [transform-api-docs]: https://api.emberjs.com/ember-data/release/classes/Transform