Skip to content

Commit

Permalink
feat: assertive client type helper (#1076)
Browse files Browse the repository at this point in the history
  • Loading branch information
kuhe authored Nov 20, 2023
1 parent d9384c3 commit 9bfc64e
Show file tree
Hide file tree
Showing 5 changed files with 225 additions and 2 deletions.
5 changes: 5 additions & 0 deletions .changeset/clever-crews-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@smithy/types": minor
---

add type helper for nullability in clients
43 changes: 41 additions & 2 deletions packages/types/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,49 @@
This package is mostly used internally by generated clients.
Some public components have independent applications.

### Scenario: Narrowing a smithy-typescript generated client's output payload blob types

---

### Scenario: Removing `| undefined` from input and output structures

Generated shapes' members are unioned with `undefined` for
input shapes, and are `?` (optional) for output shapes.

- for inputs, this defers the validation to the service.
- for outputs, this strongly suggests that you should runtime-check the output data.

If you would like to skip these steps, use the `AssertiveClient` or
`UncheckedClient` type helpers.

Using AWS S3 as an example:

```ts
import { S3 } from "@aws-sdk/client-s3";
import type { AssertiveClient, UncheckedClient } from "@smithy/types";

const s3a = new S3({}) as AssertiveClient<S3>;
const s3b = new S3({}) as UncheckedClient<S3>;

// AssertiveClient enforces required inputs are not undefined
// and required outputs are not undefined.
const get = await s3a.getObject({
Bucket: "",
Key: "",
});

// UncheckedClient makes output fields non-nullable.
// You should still perform type checks as you deem
// necessary, but the SDK will no longer prompt you
// with nullability errors.
const body = await (
await s3b.getObject({
Bucket: "",
Key: "",
})
).Body.transformToString();
```

### Scenario: Narrowing a smithy-typescript generated client's output payload blob types

This is mostly relevant to operations with streaming bodies such as within
the S3Client in the AWS SDK for JavaScript v3.

Expand Down
1 change: 1 addition & 0 deletions packages/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export * from "./streaming-payload/streaming-blob-payload-input-types";
export * from "./streaming-payload/streaming-blob-payload-output-types";
export * from "./transfer";
export * from "./transform/client-payload-blob-type-narrow";
export * from "./transform/no-undefined";
export * from "./transform/type-transform";
export * from "./uri";
export * from "./util";
Expand Down
95 changes: 95 additions & 0 deletions packages/types/src/transform/no-undefined.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type { Client } from "../client";
import type { HttpHandlerOptions } from "../http";
import type { MetadataBearer } from "../response";
import type { Exact } from "./exact";
import type { AssertiveClient, NoUndefined, UncheckedClient } from "./no-undefined";

type A = {
a: string;
b: number | string;
c: boolean | number | string;
required: string | undefined;
optional?: string;
nested: A;
};

{
// it should remove undefined union from required fields.
type T = NoUndefined<A>;

const assert1: Exact<T["required"], string> = true as const;
const assert2: Exact<T["nested"]["required"], string> = true as const;
const assert3: Exact<T["nested"]["nested"]["required"], string> = true as const;
}

{
type MyInput = {
a: string | undefined;
b: number | undefined;
c: string | number | undefined;
optional?: string;
};

type MyOutput = {
a?: string;
b?: number;
c?: string | number;
r?: MyOutput;
} & MetadataBearer;

type MyConfig = {
version: number;
};

interface MyClient extends Client<MyInput, MyOutput, MyConfig> {
getObject(args: MyInput, options?: HttpHandlerOptions): Promise<MyOutput>;
getObject(args: MyInput, cb: (err: any, data?: MyOutput) => void): void;
getObject(args: MyInput, options: HttpHandlerOptions, cb: (err: any, data?: MyOutput) => void): void;

putObject(args: MyInput, options?: HttpHandlerOptions): Promise<MyOutput>;
putObject(args: MyInput, cb: (err: any, data?: MyOutput) => void): void;
putObject(args: MyInput, options: HttpHandlerOptions, cb: (err: any, data?: MyOutput) => void): void;
}

{
// AssertiveClient should enforce union of undefined on inputs
// but preserve undefined outputs.
const c = (null as unknown) as AssertiveClient<MyClient>;
const input = {
a: "",
b: 0,
c: 0,
};
const get = c.getObject(input);
const output = (null as unknown) as Awaited<typeof get>;

const assert1: Exact<typeof output.a, string | undefined> = true as const;
const assert2: Exact<typeof output.b, number | undefined> = true as const;
const assert3: Exact<typeof output.c, string | number | undefined> = true as const;
if (output.r) {
const assert4: Exact<typeof output.r.a, string | undefined> = true as const;
const assert5: Exact<typeof output.r.b, number | undefined> = true as const;
const assert6: Exact<typeof output.r.c, string | number | undefined> = true as const;
}
}

{
// UncheckedClient both removes union-undefined from inputs
// and the nullability of outputs.
const c = (null as unknown) as UncheckedClient<MyClient>;
const input = {
a: "",
b: 0,
c: 0,
};
const get = c.getObject(input);
const output = (null as unknown) as Awaited<typeof get>;

const assert1: Exact<typeof output.a, string> = true as const;
const assert2: Exact<typeof output.b, number> = true as const;
const assert3: Exact<typeof output.c, string | number> = true as const;
const assert4: Exact<typeof output.r.a, string> = true as const;
const assert5: Exact<typeof output.r.b, number> = true as const;
const assert6: Exact<typeof output.r.c, string | number> = true as const;
}
}
83 changes: 83 additions & 0 deletions packages/types/src/transform/no-undefined.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import type { InvokeFunction, InvokeMethod } from "../client";

/**
* @public
*
* This type is intended as a type helper for generated clients.
* When initializing client, cast it to this type by passing
* the client constructor type as the type parameter.
*
* It will then recursively remove "undefined" as a union type from all
* input and output shapes' members. Note, this does not affect
* any member that is optional (?) such as outputs with no required members.
*
* @example
* ```ts
* const client = new Client({}) as AssertiveClient<Client>;
* ```
*/
export type AssertiveClient<Client extends object> = NarrowClientIOTypes<Client>;

/**
* @public
*
* This is similar to AssertiveClient but additionally changes all
* output types to (recursive) Required<T> so as to bypass all output nullability guards.
*/
export type UncheckedClient<Client extends object> = UncheckedClientOutputTypes<Client>;

/**
* @internal
*
* Excludes undefined recursively.
*/
export type NoUndefined<T> = T extends Function
? T
: [T] extends [object]
? {
[key in keyof T]: NoUndefined<T[key]>;
}
: Exclude<T, undefined>;

/**
* @internal
*
* Excludes undefined and optional recursively.
*/
export type RecursiveRequired<T> = T extends Function
? T
: [T] extends [object]
? {
[key in keyof T]-?: RecursiveRequired<T[key]>;
}
: Exclude<T, undefined>;

/**
* @internal
*
* Removes undefined from unions.
*/
type NarrowClientIOTypes<ClientType extends object> = {
[key in keyof ClientType]: [ClientType[key]] extends [
InvokeFunction<infer InputTypes, infer OutputTypes, infer ConfigType>
]
? InvokeFunction<NoUndefined<InputTypes>, NoUndefined<OutputTypes>, ConfigType>
: [ClientType[key]] extends [InvokeMethod<infer FunctionInputTypes, infer FunctionOutputTypes>]
? InvokeMethod<NoUndefined<FunctionInputTypes>, NoUndefined<FunctionOutputTypes>>
: ClientType[key];
};

/**
* @internal
*
* Removes undefined from unions and adds yolo output types.
*/
type UncheckedClientOutputTypes<ClientType extends object> = {
[key in keyof ClientType]: [ClientType[key]] extends [
InvokeFunction<infer InputTypes, infer OutputTypes, infer ConfigType>
]
? InvokeFunction<NoUndefined<InputTypes>, RecursiveRequired<OutputTypes>, ConfigType>
: [ClientType[key]] extends [InvokeMethod<infer FunctionInputTypes, infer FunctionOutputTypes>]
? InvokeMethod<NoUndefined<FunctionInputTypes>, RecursiveRequired<FunctionOutputTypes>>
: ClientType[key];
};

0 comments on commit 9bfc64e

Please sign in to comment.