Skip to content

Commit

Permalink
Add documentation on using TypeScript with data masking (#12165)
Browse files Browse the repository at this point in the history
Co-authored-by: Lenz Weber-Tronic <[email protected]>
Co-authored-by: Maria Elisabeth Schreiber <[email protected]>
  • Loading branch information
3 people authored Dec 4, 2024
1 parent d42d9e0 commit a8d208c
Show file tree
Hide file tree
Showing 3 changed files with 308 additions and 2 deletions.
2 changes: 1 addition & 1 deletion .size-limits.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"dist/apollo-client.min.cjs": 41618,
"dist/apollo-client.min.cjs": 41619,
"import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 34354
}
304 changes: 303 additions & 1 deletion docs/source/data/fragments.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1051,6 +1051,302 @@ query GetPosts {
}
```
### Using with TypeScript
Apollo Client provides robust TypeScript support for data masking. We've integrated data masking with [GraphQL Codegen](https://the-guild.dev/graphql/codegen) and the type format generated by its [Fragment Masking](https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#fragment-masking) feature.
Masked types don't include fields from fragment spreads. As an example, let's use the following query.
```graphql
query GetCurrentUser {
currentUser {
id
...ProfileFragment
}
}

fragment ProfileFragment on User {
name
age
}
```
The type definition for the query might resemble the following:
```ts
type GetCurrentUserQuery = {
currentUser: {
__typename: "User";
id: string;
name: string;
age: number;
} | null
}
```
<Note>
This example does not use GraphQL Codegen's true type output since it includes additional types that map scalar values differently.
</Note>
This version of the `GetCurrentUserQuery` type is _unmasked_ since it includes fields from the `ProfileFragment`.
On the other hand, _masked_ types don't include fields defined in fragments.
```ts
type GetCurrentUserQuery = {
currentUser: {
__typename: "User";
id: string;
// omitted: additional internal metadata
} | null
}
```
#### Generating masked types
You generate masked types with either the [`typescript-operations` plugin](https://the-guild.dev/graphql/codegen/plugins/typescript/typescript-operations) or the [client preset](https://the-guild.dev/graphql/codegen/plugins/presets/preset-client). You can generate masked types at any stage in the adoption process, even before enabling `dataMasking` in your client instance. Until you [opt in to use masked types](#opting-in-to-use-masked-types), the client unwraps them and provides the full operation type.
The following sections show how to configure GraphQL Codegen to output masked types.
##### With the `typescript-operations` plugin
<Note>
Support for the `@unmask` directive was introduced with `@graphql-codegen/typescript-operations` [v4.4.0](https://github.com/dotansimha/graphql-code-generator/releases/tag/release-1732308151614)
</Note>
Add the following configuration to your GraphQL Codegen config.
```ts title="codegen.ts"
const config: CodegenConfig = {
// ...
generates: {
"path/to/types.ts": {
plugins: ["typescript-operations"],
config: {
// ...
inlineFragmentTypes: "mask",
customDirectives: {
apolloUnmask: true
}
}
}
}
}
```
##### With the `client-preset`
<Note>
Support for the `@unmask` directive was introduced with `@graphql-codegen/client-preset` [v4.5.1](https://github.com/dotansimha/graphql-code-generator/releases/tag/release-1732308151614)
</Note>
Add the following configuration to your GraphQL Codegen config.
<Caution>
The [Fragment Masking](https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#fragment-masking) feature in the client preset is incompatible with Apollo Client's data masking feature. You need to [turn off Fragment Masking](https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#how-to-disable-fragment-masking) in your configuration (included below). If you use the [generated `useFragment` function](https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#the-usefragment-helper), you must use Apollo Client's [`useFragment`](#usefragment) hook instead.
</Caution>
```ts title="codegen.ts"
const config: CodegenConfig = {
// ...
generates: {
"path/to/gql/": {
preset: "client",
presetConfig: {
// ...
// disables the incompatible GraphQL Codegen fragment masking feature
fragmentMasking: false,
inlineFragmentTypes: "mask",
customDirectives: {
apolloUnmask: true
}
}
}
}
}
```
#### Opting in to use masked types
<Note>
We recommend that you opt in to use masked types only after you've [enabled `dataMasking`](#enabling-data-masking) in your `ApolloClient` instance.
</Note>
By default, the client unwraps operation types and provides the full operation result type. To prevent this behavior and have the client use masked types, you need to opt in. This strategy allows for [incremental adoption](#incremental-adoption-in-an-existing-application) in your application and avoids the need to update large parts of your application to satisfy the change in types.
You can opt in to use the masked types in one of two ways.
##### Opting in globally
To turn on masked types for your whole application at once, modify the `DataMasking` exported type from `@apollo/client` using TypeScript's [declaration merging](https://www.typescriptlang.org/docs/handbook/declaration-merging.html) ability.
<Tip>
We recommend this approach for most cases. Use this approach with the [`@unmask` directive](./directives#unmask) for the best path to [incremental adoption](#incremental-adoption-in-an-existing-application).
</Tip>
First, create a TypeScript file that will be used to modify the `DataMasking` type.
```ts title="apollo-client.d.ts"
// This import is necessary to ensure all Apollo Client imports
// are still available to the rest of the application.
import '@apollo/client';

declare module "@apollo/client" {
interface DataMasking {
enabled: true;
}
}
```
<Note>
This example uses `apollo-client.d.ts` as the file name to make it easily identifiable. You can name this file as you wish.
</Note>
With `enabled` set to `true`, any request-based API will type `data` using the masked type and prevent the client from unwrapping it.
##### Opting in per operation
If you prefer an incremental approach, you can opt in to use masked types per operation. This can be useful when your application creates multiple Apollo Client instances where only a subset enables data masking.
Apollo Client provides a `Masked` helper type that tells the client to use the masked type directly. You can use this with `TypedDocumentNode` or with generic arguments.
```ts
import { Masked, TypedDocumentNode } from "@apollo/client";

// With TypedDocumentNode
const QUERY: TypedDocumentNode<Masked<QueryType>, VarsType> = gql`
# ...
`;

// with generic arguments
const { data } = useQuery<Masked<QueryType>, VarsType>(QUERY)
```
The use of `TypedDocumentNode` with the `Masked` type is common enough that Apollo Client provides a `MaskedDocumentNode` convenience type as a replacement for `TypedDocumentNode`. It is simply a shortcut for `TypedDocumentNode<Masked<QueryType>, VarsType>`.
```ts
import { MaskedDocumentNode } from "@apollo/client";

const QUERY: MaskedDocumentNode<QueryType, VarsType> = gql`
# ...
`;
```
#### Using with fragments
When using [colocated fragments](#colocating-fragments) with your components, it's best to ensure the object passed to your component is done in a type-safe way. This means:
- TypeScript prevents you from accessing fields on the object that may be defined with the parent.
- The object passed to the component is guaranteed to contain a fragment reference of the same type.
Apollo Client provides the `FragmentType` helper type for this purpose. As an example, let's use the `PostDetails` fragment from previous sections.
```tsx {1,14} title="PostDetails.tsx"
import type { FragmentType } from "@apollo/client";
import type { PostDetailsFragment } from "./path/to/gql/types.ts";

export const POST_DETAILS_FRAGMENT: TypedDocumentNode<
PostDetailsFragment
> = gql`
fragment PostDetailsFragment on Post {
title
shortDescription
}
`;

interface PostDetailsProps {
post: FragmentType<PostDetailsFragment>
}

function PostDetails({ post }: PostDetailsProps) {
const { data } = useFragment({
fragment: POST_DETAILS_FRAGMENT,
from: post,
});

// ...
}
```
Using properties from the `post` prop instead of the `data` from `useFragment` results in a TypeScript error similar to the following:
```ts
function PostDetails({ post }: PostDetailsProps) {
// ...

post.title
// ❌ Property 'title' does not exist on type '{ " $fragmentRefs"?: { PostDetailsFragment: PostDetailsFragment; } | undefined; }'
}
```
`FragmentType` also prevents parent components from accidentally omitting fragment spreads for child components, regardless of whether the field selection satisfies the fragment's data requirements.
```tsx title="Posts.tsx"
const GET_POSTS = gql`
query GetPosts {
posts {
id
title
shortDescription
}
}
`;

export default function Posts() {
// ...

return (
<div>
{allPosts.map((post) => (
<PostDetails key={post.id} post={post} />
// ❌ Type '{ __typename: "Post"; id: string; title: string; shortDescription: string; }' has no properties in common with type '{ " $fragmentRefs"?: { PostDetailsFragment: PostDetailsFragment; } | undefined; }'.
))}
</div>
);
}
```
In this example, the `GetPosts` query selects enough fields to satisfy the `PostDetails` data requirements, but TypeScript warns us because the `PostDetailsFragment` was not included in the `GetPosts` query.
#### Unwrapping masked types
On rare occasions, you may need access to the unmasked type of a particular operation. Apollo Client provides the `Unmasked` helper type that unwraps masked types and removes meta information on the type.
<Note>
This is the same helper type the client uses when unwrapping types while data masking is turned off or for APIs that use the full result.
</Note>
```ts
import { Unmasked } from "@apollo/client";

type QueryType = {
currentUser: {
__typename: "User";
id: string;
name: string;
} & { " $fragmentRefs"?: { UserFragment: UserFragment } }
}

type UserFragment = {
__typename: "User";
age: number | null;
} & { " $fragmentName"?: "UserFragment" }

type UnmaskedQueryType = Unmasked<QueryType>;
// ^? type UnmaskedQueryType = {
// currentUser: {
// __typename: "User";
// id: string;
// name: string;
// age: number | null;
// }
// }
```
<Note>
This example does not use GraphQL Codegen's true type output since it includes additional types that map scalar values differently.
</Note>
### Incremental adoption in an existing application
Existing applications can take advantage of the data masking features through an incremental adoption approach. This section will walk through the steps needed to adopt data masking in a larger codebase.
Expand Down Expand Up @@ -1121,7 +1417,13 @@ new ApolloClient({
> Enabling data masking early in the adoption process makes it much easier to adopt for newly added queries and fragments since masking becomes the default behavior. Ideally data masking is enabled in the same pull request as the `@unmask` changes to ensure that no new queries and fragments are introduced to the codebase without the `@unmask` modifications applied.
#### 3. Use `useFragment`
#### 3. Generate and opt in to use masked types
If you are using TypeScript in your application, you will need to update your GraphQL Codegen configuration to [generate masked types](#generating-masked-types). Once you generate masked types, [opt in](#opting-in-to-use-masked-types) to use the masking types with your client.
Learn more about using TypeScript with data masking in the ["Using with TypeScript"](#using-with-typescript) section.
#### 4. Use `useFragment`
With data masking enabled, you can now begin the process of refactoring your components to use data masking. It is easiest to look for areas of the codebase where you see field access warnings in the console on would-be masked fields (requires migrate mode).
Expand Down
4 changes: 4 additions & 0 deletions docs/source/development-testing/static-typing.md
Original file line number Diff line number Diff line change
Expand Up @@ -636,3 +636,7 @@ export function RocketInventoryList() {
);
}
```
## Data masking
Learn more about integrating TypeScript with data masking in the [data masking docs](../data/fragments#using-with-typescript).

0 comments on commit a8d208c

Please sign in to comment.