Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add brandedAliases option for strongly branded aliases #225

Open
wants to merge 2 commits into
base: develop
Choose a base branch
from
Open
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
57 changes: 56 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Options:
--help Show help [boolean]
--packageVersion The version of the generated package [string]
--packageName The name of the generated package [string]
--brandedAliases Generates strongly branded types for compatible aliases. [boolean] [default: false]
--flavorizedAliases Generates flavoured types for compatible aliases. [boolean] [default: false]
--nodeCompatibleModules Generate node compatible javascript [boolean] [default: false]
--rawSource Generate raw source without any package metadata [boolean] [default: false]
Expand Down Expand Up @@ -109,7 +110,61 @@ We also consider the command line interface and feature flags to be public API.

- **Conjure alias**

TypeScript uses structural (duck-typing) so aliases are currently elided.
By default alias types are not generated and the underlying data type is use instead.
However typed Aliases can be generated with either branded or flavored nominal types, with a tradeoff between the two.

Branded aliases (via `--brandedAliases`) are stronger typed and require type assertions for more intentional usage, similar to the conjure-java generator.

```typescript
// generated from conjure:
// FooId:
// alias: string
export type IFooId = string & {
__conjure_type: "FooId",
__conjure_package: "com.palantir.product",
};
// generated from conjure:
// BarId:
// alias: string
export type IBarId = string & {
__conjure_type: "BarId",
__conjure_package: "com.palantir.product",
};

let foo: IFooId;
foo = "foo"; // compile error
foo = "foo" as IFooId; // explicit type assertion ok

const bar: IBarId = value; // compile error, different alias types do not mix

const id: string = foo; // ok, can set string value from alias
const bar2: IBarId = id; // compile error, cannot set alias type from string without type assertion.
```

Flavored types (via `--flavorizedAliases`) protect only between aliases but still allow implicit casting to & from the base type.

```typescript
// generated from conjure:
// FooId:
// alias: string
export type IFooId = string & {
__conjure_type?: "FooId",
__conjure_package?: "com.palantir.product",
};
// generated from conjure:
// BarId:
// alias: string
export type IBarId = string & {
__conjure_type?: "BarId",
__conjure_package?: "com.palantir.product",
};

const foo: IFooId = "foo"; // ok to set from string
const bar: IBarId = foo; // compile error, different alias types do not mix

const id: string = foo; // ok, can set string value from alias
const bar2: IBarId = id; // ok, but no protection from string back to alias type this may mask an unintentional bug depending on usage.
```

## Example Client interfaces

Expand Down
5 changes: 5 additions & 0 deletions changelog/@unreleased/pr-225.v2.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
type: feature
feature:
description: Add brandedAliases option for strongly branded aliases
links:
- https://github.com/palantir/conjure-typescript/pull/225
11 changes: 9 additions & 2 deletions src/commands/generate/__tests__/generatorTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import { generate } from "../generator";
import { typeNameToFilePath } from "../simpleAst";
import { ITypeGenerationFlags } from "../typeGenerationFlags";
import { isFlavorizable } from "../utils";
import { DEFAULT_TYPE_GENERATION_FLAGS, FLAVORED_TYPE_GENERATION_FLAGS, READONLY_TYPE_GENERATION_FLAGS } from "./resources/constants";
import { BRANDED_TYPE_GENERATION_FLAGS, DEFAULT_TYPE_GENERATION_FLAGS, FLAVORED_TYPE_GENERATION_FLAGS, READONLY_TYPE_GENERATION_FLAGS } from "./resources/constants";
import { assertOutputAndExpectedAreEqual } from "./testTypesGeneratorTest";

describe("generator", () => {
Expand Down Expand Up @@ -106,6 +106,7 @@ export { integrationSecond };

const irDir = path.join(__dirname, "../../../../build/ir-test-cases");
const testCaseDir = path.join(__dirname, "resources/test-cases");
const brandedTestCaseDir = path.join(__dirname, "resources/branded-test-cases");
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I was torn on duplicating the test-cases again and having a whole other copy of these generated files for this generatorTest, but I think it's good to be clear and actually differentiate the flavored from branded.

It does make for some noise now in this PR though, at least you can skip over anything src/commands/generate/__tests__/resources/branded-test-cases/** (they're also the only added files)

const flavoredTestCaseDir = path.join(__dirname, "resources/flavored-test-cases");
const readonlyTestCaseDir = path.join(__dirname, "resources/readonly-test-cases");

Expand All @@ -114,11 +115,17 @@ describe("definitionTests", () => {
const definitionFilePath = path.join(irDir, fileName);
const paths = fileName.substring(0, fileName.lastIndexOf("."));
const actualTestCaseDir = path.join(testCaseDir, paths);
const actualBrandedTestCaseDir = path.join(brandedTestCaseDir, paths);
const actualFlavoredTestCaseDir = path.join(flavoredTestCaseDir, paths);
const actualReadonlyTestCaseDir = path.join(readonlyTestCaseDir, paths);

it(`${fileName} produces equivalent TypeScript`, testGenerateAllFilesAreTheSame(definitionFilePath, paths, actualTestCaseDir, DEFAULT_TYPE_GENERATION_FLAGS));

// Not every test has a branded version
if (fs.existsSync(actualBrandedTestCaseDir)) {
it(`${fileName} produces equivalent branded TypeScript`, testGenerateAllFilesAreTheSame(definitionFilePath, paths, actualBrandedTestCaseDir, BRANDED_TYPE_GENERATION_FLAGS));
}

// Not every test has a flavored version
if (fs.existsSync(actualFlavoredTestCaseDir)) {
it(`${fileName} produces equivalent flavored TypeScript`, testGenerateAllFilesAreTheSame(definitionFilePath, paths, actualFlavoredTestCaseDir, FLAVORED_TYPE_GENERATION_FLAGS));
Expand Down Expand Up @@ -152,7 +159,7 @@ function expectAllFilesAreTheSame(
) {
for (const type of definition.types) {
// We do not generate flavoured types for all aliases
if (type.type === "alias" && !isFlavorizable(type.alias.alias, typeGenerationFlags.flavorizedAliases)) {
if (type.type === "alias" && !isFlavorizable(type.alias.alias, typeGenerationFlags.aliases)) {
continue;
}
const relativeFilePath = typeNameToFilePath(ITypeDefinition.visit(type, typeNameVisitor));
Expand Down
Loading