Skip to content

Commit

Permalink
Support the Pick utility type for a prop (#414)
Browse files Browse the repository at this point in the history
This PR adds support for the `Pick` TypeScript utility type when defining the type of a prop in a component's prop interface. As mentioned in the previous PR, utility types are still not allowed when defining the overall type of the component's prop interface (i.e. `props: Pick<MyProps, "blah">`).

J=SLAP-2968
TEST=auto, manual

In the test site, see that using `Pick<ObjectProp, "nestedBool">` instead of `ObjectProp` for the type of `obj` in `BannerData` correctly results in the prop editor for `nestedBool` being the only one that appears in the UI for that prop.
  • Loading branch information
nmanu1 authored Oct 19, 2023
1 parent 003f15f commit b3b57a7
Show file tree
Hide file tree
Showing 11 changed files with 213 additions and 27 deletions.
2 changes: 1 addition & 1 deletion packages/studio-plugin/src/parsers/PropShapeParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
ParsedShape,
ParsedType,
ParsedTypeKind,
} from "./helpers/TypeNodeParsingHelper";
} from "./helpers/TypeNodeParsingHelpers";

/**
* PropShapeParser is a class for parsing a typescript interface into a PropShape.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import upath from "upath";
import vm from "vm";
import TypeNodeParsingHelper, {
ParsedType,
} from "./helpers/TypeNodeParsingHelper";
} from "./helpers/TypeNodeParsingHelpers";
import { parseSync as babelParseSync } from "@babel/core";
import NpmLookup from "./helpers/NpmLookup";
import { TypelessPropVal } from "../types";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { UnionTypeNode, SyntaxKind, LiteralTypeNode, TypeNode } from "ts-morph";
import { ParsedType, ParsedTypeKind } from "./TypeNodeParsingHelper";
import { ParsedType, ParsedTypeKind } from "./TypeNodeParsingHelpers";

export default class StringUnionParsingHelper {
static parseStringUnion(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { TypeNode, TypeReferenceNode } from "ts-morph";
import { ParsedType, ParsedTypeKind } from "./TypeNodeParsingHelper";
import {
ParsedShape,
ParsedType,
ParsedTypeKind,
} from "./TypeNodeParsingHelpers";

type UtilityTypeHandler = (
typeArgs: TypeNode[],
parseTypeNode: (node: TypeNode) => ParsedType
) => ParsedType;

export default class UtilityTypeParsingHelper {
static parseUtilityType(
Expand All @@ -9,15 +18,18 @@ export default class UtilityTypeParsingHelper {
const typeName = typeRefNode.getTypeName().getText();
const typeArgs = typeRefNode.getTypeArguments();

if (typeName === "Omit") {
return this.handleOmit(typeArgs, parseTypeNode);
}
const utilityTypeHandler: UtilityTypeHandler | undefined = (() => {
switch (typeName) {
case "Omit":
return this.handleOmit;
case "Pick":
return this.handlePick;
}
})();
return utilityTypeHandler?.(typeArgs, parseTypeNode);
}

private static handleOmit(
typeArgs: TypeNode[],
parseTypeNode: (node: TypeNode) => ParsedType
): ParsedType {
private static handleOmit: UtilityTypeHandler = (typeArgs, parseTypeNode) => {
if (typeArgs.length !== 2) {
throw new Error(
`Two type params expected for Omit utility type. Found ${typeArgs.length}.`
Expand All @@ -41,5 +53,50 @@ export default class UtilityTypeParsingHelper {
` Found ${omitType}.`
);
}
}
};

private static handlePick: UtilityTypeHandler = (typeArgs, parseTypeNode) => {
if (typeArgs.length !== 2) {
throw new Error(
`Two type params expected for Pick utility type. Found ${typeArgs.length}.`
);
}

const [fullType, pickType] = typeArgs.map(parseTypeNode);

if (fullType.kind !== ParsedTypeKind.Object) {
throw new Error(
"Only object types are supported for first type arg of Pick utility type." +
` Found ${fullType}.`
);
}
if (
!pickType.unionValues &&
pickType.kind !== ParsedTypeKind.StringLiteral
) {
throw new Error(
"Expected string literal or union of string literals for second type arg of Pick utility type." +
` Found ${pickType}.`
);
}

const pickKeys = pickType.unionValues ?? [pickType.type];
const reducedShape: ParsedShape = pickKeys.reduce((reducedShape, key) => {
const parsedProperty = fullType.type[key];
if (!parsedProperty) {
throw new Error(
`Cannot pick key ${key} that is not present in type ${fullType.type}.`
);
}
return {
...reducedShape,
[key]: parsedProperty,
};
}, {});

return {
...fullType,
type: reducedShape,
};
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
import StudioSourceFileWriter from "./StudioSourceFileWriter";
import ComponentTreeHelpers from "../utils/ComponentTreeHelpers";
import camelCase from "camelcase";
import { CustomTags } from "../parsers/helpers/TypeNodeParsingHelper";
import { CustomTags } from "../parsers/helpers/TypeNodeParsingHelpers";
import getImportSpecifier from "../utils/getImportSpecifier";

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { SyntaxKind } from "ts-morph";
import StudioSourceFileParser from "../../src/parsers/StudioSourceFileParser";
import createTestSourceFile from "../__utils__/createTestSourceFile";
import expectSyntaxKind from "../__utils__/expectSyntaxKind";
import { ParsedTypeKind } from "../../src/parsers/helpers/TypeNodeParsingHelper";
import { ParsedTypeKind } from "../../src/parsers/helpers/TypeNodeParsingHelpers";
import upath from "upath";

describe("parseExportedObjectLiteral", () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { JsxAttributeLike, SyntaxKind } from "ts-morph";
import { PropShape } from "../../src/types/PropShape";
import { PropShape } from "../../../src/types/PropShape";
import {
PropValueKind,
PropValueType,
PropValues,
} from "../../src/types/PropValues";
import StaticParsingHelpers from "../../src/parsers/helpers/StaticParsingHelpers";
import createTestSourceFile from "../__utils__/createTestSourceFile";
import expectSyntaxKind from "../__utils__/expectSyntaxKind";
} from "../../../src/types/PropValues";
import StaticParsingHelpers from "../../../src/parsers/helpers/StaticParsingHelpers";
import createTestSourceFile from "../../__utils__/createTestSourceFile";
import expectSyntaxKind from "../../__utils__/expectSyntaxKind";

describe("parseObjectLiteral", () => {
it("parsing an object literal with an expression", () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { SyntaxKind } from "ts-morph";
import StringUnionParsingHelper from "../../../src/parsers/helpers/StringUnionParsingHelper";
import createTestSourceFile from "../../__utils__/createTestSourceFile";
import { ParsedTypeKind } from "../../../src/parsers/helpers/TypeNodeParsingHelper";
import { ParsedTypeKind } from "../../../src/parsers/helpers/TypeNodeParsingHelpers";

it("does not handle StringKeywords within unions", () => {
const { sourceFile } = createTestSourceFile(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { SyntaxKind } from "ts-morph";
import TypeNodeParsingHelper, {
ParsedTypeKind,
} from "../../../src/parsers/helpers/TypeNodeParsingHelper";
} from "../../../src/parsers/helpers/TypeNodeParsingHelpers";
import createTestSourceFile from "../../__utils__/createTestSourceFile";
import { PropValueType } from "../../../src/types";

Expand Down Expand Up @@ -184,7 +184,7 @@ it("throws an error if Array TypeReference is missing a type param", () => {
).toThrowError("One type param expected for Array type. Found 0.");
});

it("can parse a prop with an Omit utility type", () => {
it("can parse a prop with a utility type", () => {
const { sourceFile } = createTestSourceFile(
`export type MyProps = {
omit: Omit<{ name?: string, nope: number }, "nope">;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
ParsedProperty,
ParsedType,
ParsedTypeKind,
} from "../../../src/parsers/helpers/TypeNodeParsingHelper";
} from "../../../src/parsers/helpers/TypeNodeParsingHelpers";
import cloneDeep from "lodash/cloneDeep";

const nameProp: ParsedProperty = {
Expand Down Expand Up @@ -62,10 +62,7 @@ describe("omit", () => {
type: "num",
})
);
expect(parsedType?.type).toEqual({
name: nameProp,
arr: arrProp,
});
expect(parsedType?.type).toEqual({ name: nameProp, arr: arrProp });
});

it("can handle multiple omitted types", () => {
Expand Down Expand Up @@ -176,3 +173,135 @@ describe("omit", () => {
);
});
});

describe("pick", () => {
it("can handle a single picked type", () => {
const { sourceFile } = createTestSourceFile(
`type MyPick = Pick<{ name?: string, num: number, arr: boolean[] }, "num">;
}`
);
const typeRef = sourceFile.getFirstDescendantByKindOrThrow(
SyntaxKind.TypeReference
);
const parsedType = UtilityTypeParsingHelper.parseUtilityType(
typeRef,
mockedParseTypeNodeCreator({
kind: ParsedTypeKind.StringLiteral,
type: "num",
})
);
expect(parsedType?.type).toEqual({ num: numProp });
});

it("can handle multiple picked types", () => {
const { sourceFile } = createTestSourceFile(
`type MyPick = Pick<{ name?: string, num: number, arr: boolean[] }, "name" | "num">;
}`
);
const typeRef = sourceFile.getFirstDescendantByKindOrThrow(
SyntaxKind.TypeReference
);
const parsedType = UtilityTypeParsingHelper.parseUtilityType(
typeRef,
mockedParseTypeNodeCreator({
kind: ParsedTypeKind.Simple,
type: "string",
unionValues: ["name", "num"],
})
);
expect(parsedType?.type).toEqual({ name: nameProp, num: numProp });
});

it("throws an error if there an incorrect number of type arguments", () => {
const { sourceFile } = createTestSourceFile(
`type MyPick = Pick<{ name?: string, num: number, arr: boolean[] }, "name", "num">;
}`
);
const typeRef = sourceFile.getFirstDescendantByKindOrThrow(
SyntaxKind.TypeReference
);

expect(() =>
UtilityTypeParsingHelper.parseUtilityType(
typeRef,
mockedParseTypeNodeCreator({
kind: ParsedTypeKind.StringLiteral,
type: "name",
})
)
).toThrowError("Two type params expected for Pick utility type. Found 3.");
});

it("throws an error if original type is not an object type", () => {
const { sourceFile } = createTestSourceFile(
`type MyPick = Pick<"test", "test">;
}`
);
const typeRef = sourceFile.getFirstDescendantByKindOrThrow(
SyntaxKind.TypeReference
);
const testParsedType: ParsedType = {
kind: ParsedTypeKind.StringLiteral,
type: "test",
};

expect(() =>
UtilityTypeParsingHelper.parseUtilityType(
typeRef,
mockedParseTypeNodeCreator(testParsedType, testParsedType)
)
).toThrowError(
`Only object types are supported for first type arg of Pick utility type. Found ${testParsedType}.`
);
});

it("throws an error if the pick type is not a string literal or union of string literals", () => {
const { sourceFile } = createTestSourceFile(
`type MyPick = Pick<{ name?: string, num: number, arr: boolean[] }, Array<string>>;
}`
);
const typeRef = sourceFile.getFirstDescendantByKindOrThrow(
SyntaxKind.TypeReference
);

const pickType: ParsedType = {
kind: ParsedTypeKind.Array,
type: {
kind: ParsedTypeKind.Simple,
type: "string",
},
};

expect(() =>
UtilityTypeParsingHelper.parseUtilityType(
typeRef,
mockedParseTypeNodeCreator(pickType)
)
).toThrowError(
`Expected string literal or union of string literals for second type arg of Pick utility type. Found ${pickType}.`
);
});

it("throws an error if pick types include one not a key in original type", () => {
const { sourceFile } = createTestSourceFile(
`type MyPick = Pick<{ name?: string, num: number, arr: boolean[] }, "name" | "boo">;
}`
);
const typeRef = sourceFile.getFirstDescendantByKindOrThrow(
SyntaxKind.TypeReference
);

expect(() =>
UtilityTypeParsingHelper.parseUtilityType(
typeRef,
mockedParseTypeNodeCreator({
kind: ParsedTypeKind.Simple,
type: "string",
unionValues: ["name", "boo"],
})
)
).toThrowError(
`Cannot pick key boo that is not present in type ${fullObjectType.type}.`
);
});
});

0 comments on commit b3b57a7

Please sign in to comment.