Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
0fb96cb
Initial plan
Copilot Jun 3, 2026
26d634d
feat(compiler): allow @encode(string) on boolean
Copilot Jun 3, 2026
dbd1269
docs: simplify boolean @encode(string) wording
Copilot Jun 3, 2026
8237396
test(http-specs): add boolean @encode(string) spector scenario
Copilot Jun 3, 2026
ab2b2d1
test(http-specs): refine boolean encode scenario naming
Copilot Jun 3, 2026
2e01d67
test(http-specs): clarify boolean string encode route
Copilot Jun 3, 2026
1f88780
test(http-specs): move boolean encode scenario to encode/boolean
Copilot Jun 3, 2026
d015290
Address encode boolean docs and scenario feedback
Copilot Jun 3, 2026
888a49e
test(http-specs): use doc comment in boolean encode spec
Copilot Jun 3, 2026
397cbd1
test(http-specs): accept case-insensitive boolean request casing
Copilot Jun 3, 2026
5df2924
feat(spec-api): add case-insensitive boolean string matcher
Copilot Jun 5, 2026
4c3aba6
refactor(spec-api): generalize case-insensitive string matcher
Copilot Jun 5, 2026
8cfc913
chore(spec-api): simplify string matcher error messages
Copilot Jun 5, 2026
f095d13
chore(compiler): regenerate generated defs
Copilot Jun 5, 2026
7d5c391
style: format boolean encode spec and string matcher tests
Copilot Jun 5, 2026
c58089f
chore: add chronus changelog entries for encode boolean updates
Copilot Jun 5, 2026
2344e9a
Merge branch 'main' into copilot/relax-encode-for-boolean
timotheeguerin Jun 24, 2026
da8cac9
chore: regen docs and add changelog example
Copilot Jun 24, 2026
1bc774d
Merge branch 'main' into copilot/relax-encode-for-boolean
timotheeguerin Jun 24, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
changeKind: feature
packages:
- "@typespec/compiler"
- "@typespec/spec-api"
- "@typespec/http-specs"
---

Allow `@encode(string)` on boolean targets, define case-insensitive `true`/`false` string semantics, and add shared case-insensitive string matcher support with encode/boolean Spector coverage.
Comment thread
timotheeguerin marked this conversation as resolved.

```tsp
model FeatureFlags {
@encode(string)
enabled: boolean;
}
```
11 changes: 10 additions & 1 deletion packages/compiler/generated-defs/TypeSpec.ts
Comment thread
timotheeguerin marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export type MediaTypeHintDecorator = (
/**
* Specify how to encode the target type.
*
* @param encodingOrEncodeAs Known name of an encoding or a scalar type to encode as(Only for numeric types to encode as string).
* @param encodingOrEncodeAs Known name of an encoding or a scalar type to encode as(Only for numeric and boolean types to encode as string).
* @param encodedAs What target type is this being encoded as. Default to string.
* @example offsetDateTime encoded with rfc7231
*
Expand All @@ -98,6 +98,15 @@ export type MediaTypeHintDecorator = (
* @encode(string) id: int64;
* }
* ```
* @example encode boolean type to string
*
* `@encode(string)` on boolean uses case-insensitive `true` / `false` values.
*
* ```tsp
* model FeatureFlags {
* @encode(string) enabled: boolean;
* }
* ```
*/
export type EncodeDecorator = (
context: DecoratorContext,
Expand Down
12 changes: 11 additions & 1 deletion packages/compiler/lib/std/decorators.tsp
Original file line number Diff line number Diff line change
Expand Up @@ -566,7 +566,7 @@ enum ArrayEncoding {

/**
* Specify how to encode the target type.
* @param encodingOrEncodeAs Known name of an encoding or a scalar type to encode as(Only for numeric types to encode as string).
* @param encodingOrEncodeAs Known name of an encoding or a scalar type to encode as(Only for numeric and boolean types to encode as string).
* @param encodedAs What target type is this being encoded as. Default to string.
*
* @example offsetDateTime encoded with rfc7231
Expand All @@ -590,6 +590,16 @@ enum ArrayEncoding {
* @encode(string) id: int64;
* }
* ```
*
* @example encode boolean type to string
*
* `@encode(string)` on boolean uses case-insensitive `true` / `false` values.
*
* ```tsp
* model FeatureFlags {
* @encode(string) enabled: boolean;
* }
* ```
*/
extern dec encode(
target: Scalar | ModelProperty,
Expand Down
2 changes: 1 addition & 1 deletion packages/compiler/src/core/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -951,7 +951,7 @@ const diagnostics = {
wrongType: paramMessage`Encoding '${"encoding"}' cannot be used on type '${"type"}'. Expected: ${"expected"}.`,
wrongEncodingType: paramMessage`Encoding '${"encoding"}' on type '${"type"}' is expected to be serialized as '${"expected"}' but got '${"actual"}'.`,
wrongNumericEncodingType: paramMessage`Encoding '${"encoding"}' on type '${"type"}' is expected to be serialized as '${"expected"}' but got '${"actual"}'. Set '@encode' 2nd parameter to be of type ${"expected"}. e.g. '@encode("${"encoding"}", int32)'`,
firstArg: `First argument of "@encode" must be the encoding name or the string type when encoding numeric types.`,
firstArg: `First argument of "@encode" must be the encoding name or the string type when encoding numeric or boolean types.`,
},
},

Expand Down
6 changes: 4 additions & 2 deletions packages/compiler/src/lib/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -873,7 +873,9 @@ export type BytesKnownEncoding = "base64" | "base64url";
export interface EncodeData {
/**
* Known encoding key.
* Can be undefined when `@encode(string)` is used on a numeric type. In that case it just means using the base10 decimal representation of the number.
* Can be undefined when `@encode(string)` is used on a numeric or boolean type.
* For numeric this means using the base10 decimal representation of the number.
* For boolean this means using `true` or `false`.
*/
encoding?: DateTimeKnownEncoding | DurationKnownEncoding | BytesKnownEncoding | string;
type: Scalar;
Expand Down Expand Up @@ -995,7 +997,7 @@ function validateEncodeData(context: DecoratorContext, target: Type, encodeData:
case "base64url":
return check(["bytes"], ["string"]);
case undefined:
return check(["numeric"], ["string"]);
return check(["numeric", "boolean"], ["string"]);
}
}

Expand Down
16 changes: 15 additions & 1 deletion packages/compiler/test/decorators/decorators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,20 @@ describe("compiler: built-in decorators", () => {
strictEqual(encodeData.encoding, undefined);
strictEqual(encodeData.type.name, "string");
});

it(`@encode(string) on boolean model property`, async () => {
const { prop, program } = await Tester.compile(t.code`
model Foo {
@encode(string)
${t.modelProperty("prop")}: boolean;
}
`);

const encodeData = getEncode(program, prop);
ok(encodeData);
strictEqual(encodeData.encoding, undefined);
strictEqual(encodeData.type.name, "string");
});
});
describe("invalid", () => {
invalidCases.forEach(([target, encoding, encodeAs, expectedCode, expectedMessage]) => {
Expand Down Expand Up @@ -693,7 +707,7 @@ describe("compiler: built-in decorators", () => {
expectDiagnostics(diagnostics, {
code: "invalid-encode",
severity: "error",
message: "Encoding 'string' cannot be used on type 's'. Expected: numeric.",
message: "Encoding 'string' cannot be used on type 's'. Expected: numeric, boolean.",
});
});
});
Expand Down
84 changes: 84 additions & 0 deletions packages/http-specs/spec-summary.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,90 @@ Expected response body:
}
```

### Encode_Boolean_Property_falseLower

- Endpoint: `post /encode/boolean/property/false-lower`

Test operation with request and response model containing a property of boolean type with string encode.
Expected request body:

```json
{
"value": "false"
}
```

Expected response body:

```json
{
"value": "false"
}
```

### Encode_Boolean_Property_falseMixed

- Endpoint: `post /encode/boolean/property/false-mixed`

Test operation with request and response model containing a property of boolean type with string encode.
Expected request body:

```json
{
"value": "FaLsE"
}
```

Expected response body:

```json
{
"value": "FaLsE"
}
```

### Encode_Boolean_Property_trueLower

- Endpoint: `post /encode/boolean/property/true-lower`

Test operation with request and response model containing a property of boolean type with string encode.
Expected request body:

```json
{
"value": "true"
}
```

Expected response body:

```json
{
"value": "true"
}
```

### Encode_Boolean_Property_trueUpper

- Endpoint: `post /encode/boolean/property/true-upper`

Test operation with request and response model containing a property of boolean type with string encode.
Expected request body:

```json
{
"value": "TRUE"
}
```

Expected response body:

```json
{
"value": "TRUE"
}
```

### Encode_Bytes_Header_base64

- Endpoint: `get /encode/bytes/header/base64`
Expand Down
63 changes: 63 additions & 0 deletions packages/http-specs/specs/encode/boolean/main.tsp
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import "@typespec/http";
import "@typespec/spector";

using Http;
using Spector;

/** Test for encode decorator on boolean. */
@scenarioService("/encode/boolean")
namespace Encode.Boolean;

@route("/property")
namespace Property {
model BoolAsStringProperty {
@encode(string)
value: boolean;
}

alias SendBoolTrueLower = SendBoolAsString<"true">;

@route("/true-lower")
op trueLower is SendBoolTrueLower.sendBoolAsString;

alias SendBoolFalseLower = SendBoolAsString<"false">;

@route("/false-lower")
op falseLower is SendBoolFalseLower.sendBoolAsString;

alias SendBoolTrueUpper = SendBoolAsString<"TRUE">;

@route("/true-upper")
op trueUpper is SendBoolTrueUpper.sendBoolAsString;

alias SendBoolFalseMixed = SendBoolAsString<"FaLsE">;

@route("/false-mixed")
op falseMixed is SendBoolFalseMixed.sendBoolAsString;

interface SendBoolAsString<StringValue extends string> {
@scenario
@scenarioDoc(
"""
Test operation with request and response model containing a property of boolean type with string encode.
Expected request body:
```json
{
"value": "{value}"
}
```
Expected response body:
```json
{
"value": "{value}"
}
```
""",
{
value: StringValue,
}
)
@post
sendBoolAsString(@body value: BoolAsStringProperty): BoolAsStringProperty;
}
}
43 changes: 43 additions & 0 deletions packages/http-specs/specs/encode/boolean/mockapi.ts
Comment thread
timotheeguerin marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { json, match, passOnSuccess, ScenarioMockApi } from "@typespec/spec-api";

export const Scenarios: Record<string, ScenarioMockApi> = {};

function createBodyServerTests(uri: string, responseValue: string, requestValue: boolean) {
return passOnSuccess({
uri,
method: "post",
request: {
body: json({
value: match.string.caseInsensitive(String(requestValue)),
}),
},
response: {
status: 200,
body: json({
value: responseValue,
}),
},
kind: "MockApiDefinition",
});
}

Scenarios.Encode_Boolean_Property_trueLower = createBodyServerTests(
"/encode/boolean/property/true-lower",
"true",
true,
);
Scenarios.Encode_Boolean_Property_falseLower = createBodyServerTests(
"/encode/boolean/property/false-lower",
"false",
false,
);
Scenarios.Encode_Boolean_Property_trueUpper = createBodyServerTests(
"/encode/boolean/property/true-upper",
"TRUE",
true,
);
Scenarios.Encode_Boolean_Property_falseMixed = createBodyServerTests(
"/encode/boolean/property/false-mixed",
"FaLsE",
false,
);
4 changes: 4 additions & 0 deletions packages/spec-api/src/matchers/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { dateTimeMatcher } from "./datetime.js";
import { baseUrlMatcher } from "./local-url.js";
import { stringMatcher } from "./string.js";

export {
createMatcher,
Expand All @@ -18,6 +19,9 @@ export { dateTimeMatcher } from "./datetime.js";
* Namespace for built-in matchers.
*/
export const match = {
/** Matchers for comparing string values. */
string: stringMatcher,

/**
* Matchers for comparing datetime values semantically.
* Validates that the actual value is in the correct format and represents
Expand Down
24 changes: 24 additions & 0 deletions packages/spec-api/src/matchers/string.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { createMatcher, err, type MockValueMatcher, ok } from "../match-engine.js";

export const stringMatcher = {
caseInsensitive(value: string): MockValueMatcher<string> {
const normalized = value.toLowerCase();
return createMatcher({
check(actual: unknown) {
if (typeof actual !== "string") {
return err(`expected a string but got ${typeof actual}`);
}
if (actual.toLowerCase() !== normalized) {
return err(`expected case-insensitive "${value}" but got "${actual}"`);
}
return ok();
},
serialize() {
return value;
},
toString() {
return `match.string.caseInsensitive("${value}")`;
},
});
},
};
Loading
Loading