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
40 changes: 14 additions & 26 deletions src/generator/typescript.cc
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,7 @@ auto TypeScript::operator()(const IRObject &entry) const -> void {
return;
}

if (has_additional) {
this->output << "export type " << type_name << " = {\n";
} else {
this->output << "export interface " << type_name << " {\n";
}
this->output << "export interface " << type_name << " {\n";

// We always quote property names for safety. JSON Schema allows any string
// as a property name, but unquoted TypeScript/ECMAScript property names
Expand All @@ -115,32 +111,24 @@ auto TypeScript::operator()(const IRObject &entry) const -> void {
}

if (has_additional) {
this->output << "} & {\n";

// While we could do this with the more idiomatic `Record<Exclude<string,
// X>, Y>`, we choose the more verbose manner in order to allow users to
// declare a type called `Record`
this->output << " [K in string as K extends\n";

std::size_t index{0};
// TypeScript index signatures must be a supertype of all property value
// types. We use a union of all member types plus the additional properties
// type plus undefined (for optional properties).
this->output << " [key: string]:\n";
for (const auto &[member_name, member_value] : entry.members) {
const auto is_last{index == entry.members.size() - 1};
this->output << " \"" << escape_string(member_name) << "\"";
if (!is_last) {
this->output << " |";
}
this->output << "\n";
++index;
this->output << " "
<< sourcemeta::core::mangle(member_value.pointer,
this->prefix)
<< " |\n";
}

this->output << " ? never : K]: "
this->output << " "
<< sourcemeta::core::mangle(entry.additional->pointer,
this->prefix)
<< ";\n";
this->output << "};\n";
} else {
this->output << "}\n";
<< " |\n";
this->output << " undefined;\n";
}

this->output << "}\n";
}

auto TypeScript::operator()(const IRImpossible &entry) const -> void {
Expand Down
2 changes: 1 addition & 1 deletion src/ir/include/sourcemeta/codegen/ir.h
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ struct IRObjectValue : IRType {
struct IRObject : IRType {
// To preserve the user's ordering
std::vector<std::pair<sourcemeta::core::JSON::String, IRObjectValue>> members;
std::optional<IRObjectValue> additional;
std::optional<IRType> additional;
};

/// @ingroup ir
Expand Down
15 changes: 9 additions & 6 deletions src/ir/ir_default_compiler.h
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,16 @@ auto handle_object(const sourcemeta::core::JSON &schema,
members.emplace_back(entry.first, std::move(member_value));
}

std::optional<IRObjectValue> additional{std::nullopt};
std::optional<IRType> additional{std::nullopt};
if (subschema.defines("additionalProperties")) {
auto additional_pointer{sourcemeta::core::to_pointer(location.pointer)};
additional_pointer.push_back("additionalProperties");

additional =
IRObjectValue{{.pointer = std::move(additional_pointer)}, false, false};
const auto &additional_schema{subschema.at("additionalProperties")};
const auto is_false{additional_schema.is_boolean() &&
!additional_schema.to_boolean()};
if (!is_false) {
auto additional_pointer{sourcemeta::core::to_pointer(location.pointer)};
additional_pointer.push_back("additionalProperties");
additional = IRType{.pointer = std::move(additional_pointer)};
}
}

return IRObject{{.pointer = sourcemeta::core::to_pointer(location.pointer)},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export type StrictPerson_Properties_Name = string;

export type StrictPerson_Properties_Age = number;

export type StrictPerson_AdditionalProperties = never;

export interface StrictPerson {
"name": StrictPerson_Properties_Name;
"age"?: StrictPerson_Properties_Age;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"defaultPrefix": "StrictPerson"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"name": {
"type": "string"
},
"age": {
"type": "integer"
}
},
"required": [ "name" ],
"additionalProperties": false
}
73 changes: 73 additions & 0 deletions test/e2e/typescript/2020-12/additional_properties_false/test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { StrictPerson } from "./expected";

// Valid: required name only
const person1: StrictPerson = {
name: "John Doe"
};

// Valid: name and optional age
const person2: StrictPerson = {
name: "Jane Doe",
age: 25
};

// Invalid: name must be string
const person3: StrictPerson = {
// @ts-expect-error
name: 123
};

// Invalid: age must be number
const person4: StrictPerson = {
name: "John",
// @ts-expect-error
age: "twenty"
};

// Invalid: missing required name
// @ts-expect-error
const person5: StrictPerson = {
age: 30
};

// Invalid: extra string property should be rejected
const person6: StrictPerson = {
name: "John",
// @ts-expect-error
nickname: "Johnny"
};

// Invalid: extra number property should be rejected
const person7: StrictPerson = {
name: "John",
// @ts-expect-error
score: 100
};

// Invalid: extra boolean property should be rejected
const person8: StrictPerson = {
name: "John",
// @ts-expect-error
active: true
};

// Invalid: extra null property should be rejected
const person9: StrictPerson = {
name: "John",
// @ts-expect-error
nothing: null
};

// Invalid: extra array property should be rejected
const person10: StrictPerson = {
name: "John",
// @ts-expect-error
tags: [ "a", "b" ]
};

// Invalid: extra object property should be rejected
const person11: StrictPerson = {
name: "John",
// @ts-expect-error
metadata: { foo: "bar" }
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
export type FlexibleRecord_Properties_Name = string;

export type FlexibleRecord_Properties_Count = number;

export type FlexibleRecord_AdditionalProperties_AnyOf_ZIndex5 = number;

export type FlexibleRecord_AdditionalProperties_AnyOf_ZIndex4 = string;

export type FlexibleRecord_AdditionalProperties_AnyOf_ZIndex3 = unknown[];

export interface FlexibleRecord_AdditionalProperties_AnyOf_ZIndex2 {
}

export type FlexibleRecord_AdditionalProperties_AnyOf_ZIndex1 = boolean;

export type FlexibleRecord_AdditionalProperties_AnyOf_ZIndex0 = null;

export type FlexibleRecord_AdditionalProperties =
FlexibleRecord_AdditionalProperties_AnyOf_ZIndex0 |
FlexibleRecord_AdditionalProperties_AnyOf_ZIndex1 |
FlexibleRecord_AdditionalProperties_AnyOf_ZIndex2 |
FlexibleRecord_AdditionalProperties_AnyOf_ZIndex3 |
FlexibleRecord_AdditionalProperties_AnyOf_ZIndex4 |
FlexibleRecord_AdditionalProperties_AnyOf_ZIndex5;

export interface FlexibleRecord {
"name": FlexibleRecord_Properties_Name;
"count"?: FlexibleRecord_Properties_Count;
[key: string]:
FlexibleRecord_Properties_Name |
FlexibleRecord_Properties_Count |
FlexibleRecord_AdditionalProperties |
undefined;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"defaultPrefix": "FlexibleRecord"
}
14 changes: 14 additions & 0 deletions test/e2e/typescript/2020-12/additional_properties_true/schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"name": {
"type": "string"
},
"count": {
"type": "integer"
}
},
"required": [ "name" ],
"additionalProperties": true
}
69 changes: 69 additions & 0 deletions test/e2e/typescript/2020-12/additional_properties_true/test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { FlexibleRecord } from "./expected";

// Valid: required name only
const record1: FlexibleRecord = {
name: "test"
};

// Valid: name and optional count
const record2: FlexibleRecord = {
name: "test",
count: 42
};

// Valid: with string additional property
const record3: FlexibleRecord = {
name: "test",
extra: "some string"
};

// Valid: with number additional property
const record4: FlexibleRecord = {
name: "test",
extra: 123
};

// Valid: with boolean additional property
const record5: FlexibleRecord = {
name: "test",
flag: true
};

// Valid: with null additional property
const record6: FlexibleRecord = {
name: "test",
nothing: null
};

// Valid: with array additional property
const record7: FlexibleRecord = {
name: "test",
items: [ 1, 2, 3 ]
};

// Valid: with object additional property
const record8: FlexibleRecord = {
name: "test",
nested: { foo: "bar" }
};

// Valid: multiple additional properties of different types
const record9: FlexibleRecord = {
name: "test",
count: 10,
label: "my label",
active: false,
data: [ "a", "b" ]
};

// Invalid: name must be string
const record10: FlexibleRecord = {
// @ts-expect-error
name: 123
};

// Invalid: missing required name
// @ts-expect-error
const record11: FlexibleRecord = {
count: 5
};
34 changes: 6 additions & 28 deletions test/e2e/typescript/2020-12/all_type_values/expected.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,9 @@ export type AllTypes_Properties_ObjectField_Properties_Nested = string;

export type AllTypes_Properties_ObjectField_AdditionalProperties = never;

export type AllTypes_Properties_ObjectField = {
export interface AllTypes_Properties_ObjectField {
"nested"?: AllTypes_Properties_ObjectField_Properties_Nested;
} & {
[K in string as K extends
"nested"
? never : K]: AllTypes_Properties_ObjectField_AdditionalProperties;
};
}

export type AllTypes_Properties_NumberField = number;

Expand All @@ -24,17 +20,11 @@ export type AllTypes_Properties_NestedTypes_Properties_DeepBoolean = boolean;

export type AllTypes_Properties_NestedTypes_AdditionalProperties = never;

export type AllTypes_Properties_NestedTypes = {
export interface AllTypes_Properties_NestedTypes {
"deepBoolean"?: AllTypes_Properties_NestedTypes_Properties_DeepBoolean;
"deepNull"?: AllTypes_Properties_NestedTypes_Properties_DeepNull;
"deepInteger"?: AllTypes_Properties_NestedTypes_Properties_DeepInteger;
} & {
[K in string as K extends
"deepBoolean" |
"deepNull" |
"deepInteger"
? never : K]: AllTypes_Properties_NestedTypes_AdditionalProperties;
};
}

export type AllTypes_Properties_MultiType_AnyOf_ZIndex2 = null;

Expand All @@ -57,7 +47,7 @@ export type AllTypes_Properties_ArrayField = AllTypes_Properties_ArrayField_Item

export type AllTypes_AdditionalProperties = never;

export type AllTypes = {
export interface AllTypes {
"stringField": AllTypes_Properties_StringField;
"numberField": AllTypes_Properties_NumberField;
"integerField": AllTypes_Properties_IntegerField;
Expand All @@ -67,16 +57,4 @@ export type AllTypes = {
"objectField": AllTypes_Properties_ObjectField;
"multiType"?: AllTypes_Properties_MultiType;
"nestedTypes"?: AllTypes_Properties_NestedTypes;
} & {
[K in string as K extends
"stringField" |
"numberField" |
"integerField" |
"booleanField" |
"nullField" |
"arrayField" |
"objectField" |
"multiType" |
"nestedTypes"
? never : K]: AllTypes_AdditionalProperties;
};
}
Loading