Skip to content

Commit

Permalink
Support the Required utility type for a prop (#415)
Browse files Browse the repository at this point in the history
This PR adds support for the `Required` 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: Required<MyProps>`).

J=SLAP-2969
TEST=auto, manual

In the test site, see that using `Required<ObjectProp>` instead of `ObjectProp` for the type of `obj` in `BannerData` correctly results in the prop editors for `nestedString`, `nestedBool`, and `nestedObj` no longer displaying the undefined menu button icon in the UI, while all other prop editors were unchanged.
  • Loading branch information
nmanu1 authored Oct 20, 2023
1 parent b3b57a7 commit 3e4f672
Show file tree
Hide file tree
Showing 2 changed files with 164 additions and 26 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export default class UtilityTypeParsingHelper {
return this.handleOmit;
case "Pick":
return this.handlePick;
case "Required":
return this.handleRequired;
}
})();
return utilityTypeHandler?.(typeArgs, parseTypeNode);
Expand Down Expand Up @@ -99,4 +101,39 @@ export default class UtilityTypeParsingHelper {
type: reducedShape,
};
};

private static handleRequired: UtilityTypeHandler = (
typeArgs,
parseTypeNode
) => {
if (typeArgs.length !== 1) {
throw new Error(
`One type param expected for Required utility type. Found ${typeArgs.length}.`
);
}

const originalType = parseTypeNode(typeArgs[0]);

if (originalType.kind !== ParsedTypeKind.Object) {
return originalType;
}

const updatedShape: ParsedShape = Object.entries(originalType.type).reduce(
(updatedShape, [key, property]) => {
return {
...updatedShape,
[key]: {
...property,
required: true,
},
};
},
{}
);

return {
...originalType,
type: updatedShape,
};
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import createTestSourceFile from "../../__utils__/createTestSourceFile";
import UtilityTypeParsingHelper from "../../../src/parsers/helpers/UtilityTypeParsingHelper";
import {
ParsedProperty,
ParsedShape,
ParsedType,
ParsedTypeKind,
} from "../../../src/parsers/helpers/TypeNodeParsingHelpers";
Expand All @@ -22,7 +23,7 @@ const numProp: ParsedProperty = {

const arrProp: ParsedProperty = {
kind: ParsedTypeKind.Array,
required: true,
required: false,
type: {
kind: ParsedTypeKind.Simple,
type: "boolean",
Expand All @@ -38,18 +39,23 @@ const fullObjectType: ParsedType = {
},
};

const testParsedType: ParsedType = {
kind: ParsedTypeKind.StringLiteral,
type: "test",
};

function mockedParseTypeNodeCreator(
omitType: ParsedType,
fullType = cloneDeep(fullObjectType)
nonObjType = testParsedType,
objType = cloneDeep(fullObjectType)
): (node: TypeNode) => ParsedType {
return (node: TypeNode) =>
node.isKind(SyntaxKind.TypeLiteral) ? fullType : omitType;
node.isKind(SyntaxKind.TypeLiteral) ? objType : nonObjType;
}

describe("omit", () => {
it("can handle a single omitted type", () => {
const { sourceFile } = createTestSourceFile(
`type MyOmit = Omit<{ name?: string, num: number, arr: boolean[] }, "num">;
`type MyOmit = Omit<{ name?: string, num: number, arr?: boolean[] }, "num">;
}`
);
const typeRef = sourceFile.getFirstDescendantByKindOrThrow(
Expand All @@ -67,7 +73,7 @@ describe("omit", () => {

it("can handle multiple omitted types", () => {
const { sourceFile } = createTestSourceFile(
`type MyOmit = Omit<{ name?: string, num: number, arr: boolean[] }, "name" | "num">;
`type MyOmit = Omit<{ name?: string, num: number, arr?: boolean[] }, "name" | "num">;
}`
);
const typeRef = sourceFile.getFirstDescendantByKindOrThrow(
Expand All @@ -86,7 +92,7 @@ describe("omit", () => {

it("can handle non-overlapping omitted type", () => {
const { sourceFile } = createTestSourceFile(
`type MyOmit = Omit<{ name?: string, num: number, arr: boolean[] }, "boo">;
`type MyOmit = Omit<{ name?: string, num: number, arr?: boolean[] }, "boo">;
}`
);
const typeRef = sourceFile.getFirstDescendantByKindOrThrow(
Expand Down Expand Up @@ -114,21 +120,16 @@ describe("omit", () => {
const typeRef = sourceFile.getFirstDescendantByKindOrThrow(
SyntaxKind.TypeReference
);
const testParsedType: ParsedType = {
kind: ParsedTypeKind.StringLiteral,
type: "test",
};

const parsedType = UtilityTypeParsingHelper.parseUtilityType(
typeRef,
mockedParseTypeNodeCreator(testParsedType, testParsedType)
mockedParseTypeNodeCreator()
);
expect(parsedType).toEqual(testParsedType);
});

it("throws an error if there an incorrect number of type arguments", () => {
const { sourceFile } = createTestSourceFile(
`type MyOmit = Omit<{ name?: string, num: number, arr: boolean[] }, "name", "num">;
`type MyOmit = Omit<{ name?: string, num: number, arr?: boolean[] }, "name", "num">;
}`
);
const typeRef = sourceFile.getFirstDescendantByKindOrThrow(
Expand All @@ -148,7 +149,7 @@ describe("omit", () => {

it("throws an error if the omit type is not a string literal or union of string literals", () => {
const { sourceFile } = createTestSourceFile(
`type MyOmit = Omit<{ name?: string, num: number, arr: boolean[] }, Array<string>>;
`type MyOmit = Omit<{ name?: string, num: number, arr?: boolean[] }, Array<string>>;
}`
);
const typeRef = sourceFile.getFirstDescendantByKindOrThrow(
Expand Down Expand Up @@ -177,7 +178,7 @@ describe("omit", () => {
describe("pick", () => {
it("can handle a single picked type", () => {
const { sourceFile } = createTestSourceFile(
`type MyPick = Pick<{ name?: string, num: number, arr: boolean[] }, "num">;
`type MyPick = Pick<{ name?: string, num: number, arr?: boolean[] }, "num">;
}`
);
const typeRef = sourceFile.getFirstDescendantByKindOrThrow(
Expand All @@ -195,7 +196,7 @@ describe("pick", () => {

it("can handle multiple picked types", () => {
const { sourceFile } = createTestSourceFile(
`type MyPick = Pick<{ name?: string, num: number, arr: boolean[] }, "name" | "num">;
`type MyPick = Pick<{ name?: string, num: number, arr?: boolean[] }, "name" | "num">;
}`
);
const typeRef = sourceFile.getFirstDescendantByKindOrThrow(
Expand All @@ -214,7 +215,7 @@ describe("pick", () => {

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">;
`type MyPick = Pick<{ name?: string, num: number, arr?: boolean[] }, "name", "num">;
}`
);
const typeRef = sourceFile.getFirstDescendantByKindOrThrow(
Expand All @@ -240,15 +241,10 @@ describe("pick", () => {
const typeRef = sourceFile.getFirstDescendantByKindOrThrow(
SyntaxKind.TypeReference
);
const testParsedType: ParsedType = {
kind: ParsedTypeKind.StringLiteral,
type: "test",
};

expect(() =>
UtilityTypeParsingHelper.parseUtilityType(
typeRef,
mockedParseTypeNodeCreator(testParsedType, testParsedType)
mockedParseTypeNodeCreator()
)
).toThrowError(
`Only object types are supported for first type arg of Pick utility type. Found ${testParsedType}.`
Expand All @@ -257,7 +253,7 @@ describe("pick", () => {

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>>;
`type MyPick = Pick<{ name?: string, num: number, arr?: boolean[] }, Array<string>>;
}`
);
const typeRef = sourceFile.getFirstDescendantByKindOrThrow(
Expand All @@ -284,7 +280,7 @@ describe("pick", () => {

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">;
`type MyPick = Pick<{ name?: string, num: number, arr?: boolean[] }, "name" | "boo">;
}`
);
const typeRef = sourceFile.getFirstDescendantByKindOrThrow(
Expand All @@ -305,3 +301,108 @@ describe("pick", () => {
);
});
});

describe("required", () => {
it("can handle an object type", () => {
const { sourceFile } = createTestSourceFile(
`type MyRequired = Required<{ name?: string, num: number, arr?: boolean[] }>;
}`
);
const typeRef = sourceFile.getFirstDescendantByKindOrThrow(
SyntaxKind.TypeReference
);
const parsedType = UtilityTypeParsingHelper.parseUtilityType(
typeRef,
mockedParseTypeNodeCreator()
);
expect(parsedType?.type).toEqual({
name: {
kind: ParsedTypeKind.Simple,
required: true,
type: "string",
},
num: numProp,
arr: {
kind: ParsedTypeKind.Array,
required: true,
type: {
kind: ParsedTypeKind.Simple,
type: "boolean",
},
},
});
});

it("does not change optional sub-fields", () => {
const { sourceFile } = createTestSourceFile(
`type MyRequired = Required<{ obj?: { rare?: boolean; } }>;
}`
);
const typeRef = sourceFile.getFirstDescendantByKindOrThrow(
SyntaxKind.TypeReference
);
const objParsedShape: ParsedShape = {
rare: {
kind: ParsedTypeKind.Simple,
required: false,
type: "boolean",
},
};
const fullParsedType: ParsedType = {
kind: ParsedTypeKind.Object,
type: {
obj: {
kind: ParsedTypeKind.Object,
required: false,
type: objParsedShape,
},
},
};

const parsedType = UtilityTypeParsingHelper.parseUtilityType(
typeRef,
mockedParseTypeNodeCreator(undefined, fullParsedType)
);
expect(parsedType?.type).toEqual({
obj: {
kind: ParsedTypeKind.Object,
required: true,
type: objParsedShape,
},
});
});

it("does not change non-object types", () => {
const { sourceFile } = createTestSourceFile(
`type MyRequired = Required<"test">;
}`
);
const typeRef = sourceFile.getFirstDescendantByKindOrThrow(
SyntaxKind.TypeReference
);
const parsedType = UtilityTypeParsingHelper.parseUtilityType(
typeRef,
mockedParseTypeNodeCreator()
);
expect(parsedType).toEqual(testParsedType);
});

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

expect(() =>
UtilityTypeParsingHelper.parseUtilityType(
typeRef,
mockedParseTypeNodeCreator()
)
).toThrowError(
"One type param expected for Required utility type. Found 2."
);
});
});

0 comments on commit 3e4f672

Please sign in to comment.