Skip to content
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
6 changes: 6 additions & 0 deletions packages/rlc-common/src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,12 @@ export interface RLCOptions {
* `_response` property containing `rawResponse`, `parsedBody`, and `parsedHeaders`.
*/
enableStorageCompat?: boolean;
/**
* When set to true, TypeSpec `unknown` type will be translated to `Record<string, unknown>`
* instead of `any` in the generated Modular SDK. This is useful when migrating from HLC
* where `unknown` in swagger mapped to `Record<string, unknown>`.
*/
treatUnknownAsRecord?: boolean;
}

export interface ServiceInfo {
Expand Down
13 changes: 13 additions & 0 deletions packages/typespec-ts/src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ export interface EmitterOptions {
* Defaults to `false`.
*/
"enable-storage-compat"?: boolean;
/**
* When set to true, TypeSpec `unknown` type will be translated to `Record<string, unknown>`
* instead of `any` in the generated Modular SDK. This is useful when migrating from HLC
* where `unknown` in swagger mapped to `Record<string, unknown>` in the SDK.
* (Modular SDK only) Defaults to `false`.
*/
"treat-unknown-as-record"?: boolean;
}

export const RLCOptionsSchema: JSONSchemaType<EmitterOptions> = {
Expand Down Expand Up @@ -388,6 +395,12 @@ export const RLCOptionsSchema: JSONSchemaType<EmitterOptions> = {
nullable: true,
description:
"When enabled, every regular (non-LRO, non-paging) operation return type is augmented with a `_response` property containing `rawResponse` (PathUncheckedResponse), `parsedBody`, and `parsedHeaders`. Defaults to `false`."
},
"treat-unknown-as-record": {
type: "boolean",
nullable: true,
description:
"When set to true, TypeSpec `unknown` type will be translated to `Record<string, unknown>` instead of `any` in the generated Modular SDK. This is useful when migrating from HLC where `unknown` in swagger mapped to `Record<string, unknown>`. (Modular SDK only) Defaults to `false`."
}
},
required: []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3091,6 +3091,13 @@ export function checkWrapNonModelReturn(
return noWrap;
}

// Case: unknown with treatUnknownAsRecord enabled → no wrap
// When treatUnknownAsRecord is enabled, `unknown` is treated as Record<string, unknown>
// which maps to HLC PropertyKind.Dictionary → no body wrapper
if (type.kind === "unknown" && context.rlcOptions?.treatUnknownAsRecord) {
return noWrap;
}

// Remaining cases → wrap with `body`:
// - string → HLC PropertyKind.Primitive → modular `string`
// - boolean → HLC PropertyKind.Primitive → modular `boolean`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ export function getTypeExpression(
case "enum":
return getEnumExpression(context, type);
case "unknown":
return "any";
return context.rlcOptions?.treatUnknownAsRecord
Copy link
Copy Markdown
Member

@MaryGao MaryGao Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot when the treatUnknownAsRecord is enabled, we should generate noWrap for unknown type. also add an UT for unknow as return type for treatUnknownAsRecord on/off.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in commit 6d87a9f. When treatUnknownAsRecord is enabled, checkWrapNonModelReturn now returns noWrap for unknown type (treating it as a Dictionary/Record type, consistent with HLC's PropertyKind.Dictionary → no body wrapper behavior). The return type is Record<string, unknown> directly instead of being wrapped in { body: any }.

? "Record<string, unknown>"
: "any";
case "boolean":
return "boolean";
case "decimal":
Expand Down
5 changes: 4 additions & 1 deletion packages/typespec-ts/src/transform/transfromRLCOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ function extractRLCOptions(
const compatibilityQueryMultiFormat =
emitterOptions["compatibility-query-multi-format"];
const enableStorageCompat = emitterOptions["enable-storage-compat"] === true;
const treatUnknownAsRecord =
emitterOptions["treat-unknown-as-record"] === true;
const typespecTitleMap = emitterOptions["typespec-title-map"];
const hasSubscriptionId = getSubscriptionId(dpgContext);
const ignoreNullableOnOptional = getIgnoreNullableOnOptional(
Expand Down Expand Up @@ -138,7 +140,8 @@ function extractRLCOptions(
ignoreNullableOnOptional,
wrapNonModelReturn,
isMultiService,
enableStorageCompat
enableStorageCompat,
treatUnknownAsRecord
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# treat-unknown-as-record maps unknown property to Record<string, unknown>

When `treat-unknown-as-record` is enabled, TypeSpec `unknown` type properties should be mapped to `Record<string, unknown>` instead of `any` in Modular SDK.

## TypeSpec

```tsp
model Foo {
bar: unknown;
baz: unknown[];
}
op read(): Foo;
```

```yaml
treat-unknown-as-record: true
```

## Models

```ts models interface Foo
/**
* This file contains only generated model types and their (de)serializers.
* Disable the following rules for internal models with '_' prefix and deserializers which require 'any' for raw JSON input.
*/
/* eslint-disable @typescript-eslint/naming-convention */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
/** model interface Foo */
export interface Foo {
bar: Record<string, unknown>;
baz: Record<string, unknown>[];
}
```

# treat-unknown-as-record disabled maps unknown to any

When `treat-unknown-as-record` is not set, TypeSpec `unknown` type properties should be mapped to `any`.

## TypeSpec

```tsp
model Foo {
bar: unknown;
baz: unknown[];
}
op read(): Foo;
```

## Models

```ts models interface Foo
/**
* This file contains only generated model types and their (de)serializers.
* Disable the following rules for internal models with '_' prefix and deserializers which require 'any' for raw JSON input.
*/
/* eslint-disable @typescript-eslint/naming-convention */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
/** model interface Foo */
export interface Foo {
bar: any;
baz: any[];
}
```

# treat-unknown-as-record with wrap-non-model-return does not wrap unknown return type

When both `treat-unknown-as-record` and `wrap-non-model-return` are enabled, the `unknown` return type should NOT be wrapped (i.e., no `GetXxxResponse` alias is generated).

## TypeSpec

```tsp
@route("/any")
@get
op getAny(): unknown;
```

```yaml
treat-unknown-as-record: true
wrap-non-model-return: true
```

## Models

```ts models
// (file was not generated)
```

## Operations

```ts operations
import { TestingContext as Client } from "./index.js";
import { GetAnyOptionalParams } from "./options.js";
import {
StreamableMethod,
PathUncheckedResponse,
createRestError,
operationOptionsToRequestParameters,
} from "@azure-rest/core-client";

export function _getAnySend(
context: Client,
options: GetAnyOptionalParams = { requestOptions: {} },
): StreamableMethod {
return context
.path("/any")
.get({
...operationOptionsToRequestParameters(options),
headers: { accept: "application/json", ...options.requestOptions?.headers },
});
}

export async function _getAnyDeserialize(
result: PathUncheckedResponse,
): Promise<Record<string, unknown>> {
const expectedStatuses = ["200"];
if (!expectedStatuses.includes(result.status)) {
throw createRestError(result);
}

return result.body;
}

export async function getAny(
context: Client,
options: GetAnyOptionalParams = { requestOptions: {} },
): Promise<Record<string, unknown>> {
const result = await _getAnySend(context, options);
return _getAnyDeserialize(result);
}
```
8 changes: 8 additions & 0 deletions packages/typespec-ts/test/util/emitUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,10 @@ export async function emitModularModelsFromTypeSpec(
dpgContext.rlcOptions!.wrapNonModelReturn =
options["wrap-non-model-return"] === true;
}
if (options["treat-unknown-as-record"] !== undefined) {
dpgContext.rlcOptions!.treatUnknownAsRecord =
options["treat-unknown-as-record"] === true;
}
const modularEmitterOptions = transformModularEmitterOptions(dpgContext, "", {
casing: "camel"
});
Expand Down Expand Up @@ -588,6 +592,10 @@ export async function emitModularOperationsFromTypeSpec(
}
dpgContext.rlcOptions!.enableStorageCompat =
options["enable-storage-compat"] === true;
if (options["treat-unknown-as-record"] !== undefined) {
dpgContext.rlcOptions!.treatUnknownAsRecord =
options["treat-unknown-as-record"] === true;
}
const modularEmitterOptions = transformModularEmitterOptions(dpgContext, "", {
casing: "camel"
});
Expand Down
Loading